diff --git a/.gitignore b/.gitignore index b947a46..a75a883 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ output/ # IDE .idea/ .vscode/ +.codefuse/ *.swp *.swo *~ diff --git a/CHANGELOG.md b/CHANGELOG.md index 202da28..73d3eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 本文件记录各公开发布版本的变更。版本标识与发布日期与 [`release-manifest.json`](release-manifest.json) 及 [`governance/releases/`](governance/releases/) 中的发布记录保持一致;规范定版日期以 [`governance/decisions/`](governance/decisions/) 的决策记录为准。规范语义变更遵循 [GOVERNANCE.md](GOVERNANCE.md):ACT 2.1 已定版,非勘误性质的规范性变更须以新的协议版本发布。 +## Repository update — 2026-08-24 + +- 将 TSD-CRD 作为 ACT 2.1 信任服务域的信用关联子篇集成到现有发布树。 +- 在 `code/schemas/tsd-crd/reference-v1/` 发布非规范性 JSON Schema、OpenAPI、本地示例和固定测试向量,并保留既有 wire 契约与签名向量。 +- 在 `code/samples/tsd-crd-reference/` 提供非生产 Reference Implementation、Sandbox、CLI、Demo、测试和基础一致性 Runner。 +- 将 TSD-CRD 机器产物与参考实现接入根级导航、发布清单、仓库完整性检查和 `./tools/verify.sh`;通过不构成 ACT 2.1 全量 Conformance 或生产就绪声明。 + ## ACT 2.1 — 2026-08-14 - 发布 ADD、CID、PSD、TSD 四域规范,以及独立 A402 接入协议和 L1/L2/L3 场景指南。 diff --git a/NOTICE b/NOTICE index dfe34a1..a8649bd 100644 --- a/NOTICE +++ b/NOTICE @@ -6,6 +6,15 @@ International (CC-BY-4.0). Source code, JSON Schema, executable examples, tests, and automation are licensed under Apache License 2.0, except where a file or third-party notice states otherwise. +TSD-CRD Reference Implementation +Copyright (c) 2026 TSD-CRD contributors + +The TSD-CRD machine profile, reference implementation, examples, tests, and +test vectors integrated under code/schemas/tsd-crd and +code/samples/tsd-crd-reference originate from the standalone TSD-CRD project +at commit fdf7006d97ae06645dce82400fac2dff964691f2 and are licensed under +Apache License 2.0. + Third-party dependencies, linked projects, referenced standards, and product documentation remain subject to their respective terms. This NOTICE does not grant rights to third-party names, trademarks, logos, or content. diff --git a/README.en.md b/README.en.md index 12a60f0..7a4f5de 100644 --- a/README.en.md +++ b/README.en.md @@ -10,6 +10,7 @@ ACT (Agentic Commerce Trust Protocol) is an open protocol for agentic commerce. |---|---| | Read ACT 2.1 | [Specification overview](docs/specification/overview.md) | | Understand end-to-end flows | [Scenarios](docs/flows/scenarios.md) | +| Run the TSD-CRD credit-association reference flow | [TSD-CRD Reference Implementation](code/samples/tsd-crd-reference/README.md) | | Run the safe local A402 sample | [Local A402 Sample](code/samples/local-a402/README.md) | | Explore the interactive flow | [Web Showcase](code/web-client/alipay-ai-pay-showcase/README.md) | | Integrate Alipay | [Alipay Reference Integration](integrations/alipay/README.md) | @@ -47,9 +48,18 @@ npm --prefix code/web-client/alipay-ai-pay-showcase run demo The local A402 sample performs no payment. The Showcase explains the protocol flow and does not constitute a payment implementation or conformance claim. +The TSD-CRD reference suite requires Node.js 22.18 or later: + +```bash +npm --prefix code/samples/tsd-crd-reference run check +npm --prefix code/samples/tsd-crd-reference run demo +``` + +It uses Mock providers, in-memory state, temporary test keys, and the optional non-normative `reference-v1` machine profile. Passing its tests is not a claim of full ACT 2.1 conformance or production readiness. + For Alipay onboarding, credentials, sandbox operation, and current product behavior, use the [AIPay website](https://aipay.alipay.com/callpay) and its [official integration guide](https://aipay.alipay.com/docs/ai-receive/MACHINE_PAY.html). -Run all checks with: +Run all checks with Python 3, Node.js 22.18+, JDK 8+, and Maven 3.8+: ```bash ./tools/verify.sh diff --git a/README.md b/README.md index 26bae9f..6c0d100 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ ACT(Agentic Commerce Trust Protocol)是面向智能体商业交互的开放 |---|---| | 阅读 ACT 2.1 | [协议概览](docs/specification/overview.md) | | 理解完整业务流程 | [典型场景](docs/flows/scenarios.md) | +| 运行 TSD-CRD 信用关联参考链路 | [TSD-CRD Reference Implementation](code/samples/tsd-crd-reference/README.md) | | 本地运行安全的 A402 样例 | [Local A402 Sample](code/samples/local-a402/README.md) | | 查看交互演示 | [Web Showcase](code/web-client/alipay-ai-pay-showcase/README.md) | | 接入支付宝 | [Alipay Reference Integration](integrations/alipay/README.md) | @@ -18,10 +19,11 @@ ACT(Agentic Commerce Trust Protocol)是面向智能体商业交互的开放 1. 用 2 分钟阅读[协议概览](docs/specification/overview.md),先区分 ADD、CID、PSD、TSD 与 A402; 2. 遇到缩写时查看[中英术语表](docs/glossary.md); -3. 运行 Local A402 Sample,观察 `402 → Payment-Needed → 伪 Proof 被拒绝` 的安全路径; -4. 需要真实成功链路时,选择[支付宝买方](integrations/alipay/buyer-agent/README.md)或[卖方 Java](integrations/alipay/seller-java/README.md),并在官网沙箱完成授权和支付。 +3. 需要理解信用关联时,运行 TSD-CRD Reference Implementation,观察关联、映射、生命周期、查询授权和验证; +4. 运行 Local A402 Sample,观察 `402 → Payment-Needed → 伪 Proof 被拒绝` 的安全路径; +5. 需要真实支付成功链路时,选择[支付宝买方](integrations/alipay/buyer-agent/README.md)或[卖方 Java](integrations/alipay/seller-java/README.md),并在官网沙箱完成授权和支付。 -本地样例有意不伪造支付成功。真实资源交付必须来自已经验真的支付证明,因此“本地安全失败路径”和“官网沙箱成功路径”是两个不同的接入阶段。 +Local A402 Sample 有意不伪造支付成功。真实资源交付必须来自已经验真的支付证明,因此“本地安全失败路径”和“官网沙箱成功路径”是两个不同的接入阶段。TSD-CRD Reference Implementation 同样只使用 Mock 能力和测试密钥,不是生产信用服务。 ACT 2.1 的人类可读协议正文位于 `docs/specification/`。JSON Schema、fixtures 和测试位于 `code/schemas/`,用于帮助实现与验证,不增加协议正文未规定的要求。 @@ -68,6 +70,15 @@ npm --prefix code/samples/local-a402 run local 这个样例返回 `402 Payment Required`、解码 `Payment-Needed`,并验证伪造的 `Payment-Proof` 不会导致资源交付。它不连接支付产品,也不会执行支付。 +运行 TSD-CRD 测试、基础一致性检查和本地 Demo 需要 Node.js 22.18 或更高版本: + +```bash +npm --prefix code/samples/tsd-crd-reference run check +npm --prefix code/samples/tsd-crd-reference run demo +``` + +该套件采用非规范性的 `reference-v1` 机器 Profile。测试通过只证明仓库内参考路径,不等于 ACT 2.1 全量 Conformance 或生产就绪。 + 运行交互演示: ```bash @@ -88,7 +99,7 @@ npm --prefix code/web-client/alipay-ai-pay-showcase run demo ## 质量检查 -完整检查需要 Python 3、Node.js 18+、JDK 8+ 和 Maven 3.8+: +完整检查需要 Python 3、Node.js 22.18+、JDK 8+ 和 Maven 3.8+: ```bash ./tools/verify.sh diff --git a/code/README.md b/code/README.md index 6581bab..eefa6d0 100644 --- a/code/README.md +++ b/code/README.md @@ -4,8 +4,8 @@ This directory contains executable and machine-readable assets. It does not defi | Directory | Purpose | |---|---| -| [`schemas/`](schemas/README.md) | JSON Schemas, fixtures, and implementation tests | -| [`samples/`](samples/README.md) | Small runnable protocol samples | +| [`schemas/`](schemas/README.md) | JSON Schemas, fixtures, examples, test vectors, and implementation tests | +| [`samples/`](samples/README.md) | Small runnable samples and the TSD-CRD reference implementation | | [`web-client/`](web-client/README.md) | Interactive protocol demonstrations | The ACT 2.1 text is under [`docs/specification/`](../docs/specification/README.md). Product-specific code belongs under [`integrations/`](../integrations/README.md). diff --git a/code/samples/README.md b/code/samples/README.md index 431c08a..d718083 100644 --- a/code/samples/README.md +++ b/code/samples/README.md @@ -3,5 +3,9 @@ Samples are small, runnable demonstrations of protocol behavior. - [Local A402 Sample](local-a402/README.md) +- [TSD-CRD Reference Implementation](tsd-crd-reference/README.md): local + Sandbox, CLI, Demo, tests, and a basic `reference-v1` conformance runner Product-specific implementations live under [`integrations/`](../../integrations/README.md), while user-interface demonstrations live under [`code/web-client/`](../web-client/README.md). + +The TSD-CRD sample is non-normative and non-production. Its passing tests do not constitute ACT 2.1 full conformance or production trust evidence. diff --git a/code/samples/tsd-crd-reference/README.md b/code/samples/tsd-crd-reference/README.md new file mode 100644 index 0000000..168b399 --- /dev/null +++ b/code/samples/tsd-crd-reference/README.md @@ -0,0 +1,135 @@ +# TSD-CRD Reference Implementation + +ACT 2.1 信用关联子篇(TSD-CRD)的可执行参考实现、本地 Sandbox 和基础一致性测试套件。 + +本项目帮助协议实现者完成三件事: + +1. 在本地跑通信用关联、映射、生命周期、查询授权和验证流程。 +2. 查看 Reference Profile、标准报文和状态变化。 +3. 使用固定测试向量检查自己的实现是否符合 `reference-v1` Profile 的基础约束。 + +> **状态:Reference Implementation / Non-normative / Non-production。** +> 项目只使用虚构身份、虚构信用数据和临时测试密钥;测试通过不等于 ACT 2.1 全量 Conformance 或生产就绪。 + +## 协议基线 + +当前实现基于 ACT 2.1 [信任服务域中的信用关联子篇](../../../docs/specification/trust-services.md),覆盖五个组件: + +| 组件 | 作用 | +| --- | --- | +| `TSD-CRD-ASC` | 信用关联申请、确认和凭证签发 | +| `TSD-CRD-MAP` | 关联信用映射、来源标记和规则版本 | +| `TSD-CRD-LCM` | 凭证状态、暂停、恢复、撤销、过期和替换 | +| `TSD-CRD-AUTH` | 逐次授权和平台代理查询授权 | +| `TSD-CRD-VER` | 凭证级验证和关联信用信息验证 | + +协议正文规定业务语义;[`code/schemas/tsd-crd/reference-v1`](../../schemas/tsd-crd/reference-v1/README.md) 补充一套非规范性机器可读格式;本目录提供其中一种可运行实现。三者冲突时,以 ACT 2.1 协议正文为准。 + +详细说明见[协议基线](docs/protocol-baseline.md)和 [Reference Profile v1](../../schemas/tsd-crd/reference-v1/README.md)。本目录由独立 TSD-CRD 仓库提交 `fdf7006d97ae06645dce82400fac2dff964691f2` 迁入;迁入时保留 `reference-v1` 的 wire 字段和固定签名向量。 + +## 两种主体确认方式 + +| 模式 | 关联主体如何确认 | 凭证签名层数 | +| --- | --- | --- | +| `ATTESTED_CONFIRMATION` | 可信确认服务完成主体身份核验和交互确认 | 一层:签发方外层签名 | +| `DIRECT_SIGNATURE` | 关联主体使用可信主体私钥签署关联关键内容 | 两层:主体内层签名 + 签发方外层签名 | + +这里的“一层”和“两层”只指信用关联凭证的签名结构,不包括 HTTPS、回调验签或其他传输层保护。 + +协议不要求 Agent 提交公钥、使用 Agent 私钥签署挑战值或完成 `AgentControlProof`。基于 nonce 的 Agent 密钥持有证明属于可选安全扩展,不在 P0 默认流程和基础一致性测试范围内。当前仓库只有[设计说明](docs/agent-key-possession-extension.md),尚未实现对应代码。它不能替代主体确认或签发方签名。 + +## P0 范围 + +P0 包含: + +- 五个协议组件的对象、字段和不变量校验。 +- `ATTESTED_CONFIRMATION` 和 `DIRECT_SIGNATURE` 两种主体确认方式。 +- 关联信用映射三要素及 `ASSOCIATED_CREDIT` 来源标记。 +- `PENDING`、`ACTIVE`、`SUSPENDED`、`REVOKED`、`EXPIRED` 生命周期。 +- 逐次授权和平台代理查询授权。 +- 凭证级验证、关联信用信息验证、标准结果和原因码。 +- 最小披露、状态查询和验证记录。 +- 本地 Sandbox、HTTP 示例、CLI 和固定测试向量。 +- 基础一致性测试 Runner。 + +P0 不包含: + +- Agent 密钥持有证明扩展。 +- 真实身份核验、真实信用数据或真实映射模型。 +- 支付宝受理台真实接入。 +- Agent 独立信用或独立声誉。 +- 授信、支付、交易准入、反欺诈或反洗钱决策。 +- 生产数据库、KMS/HSM、多租户和高可用部署。 + +## 快速开始 + +要求: + +- Node.js `>= 22.18` +- npm(随 Node.js 提供) + +项目没有第三方运行时或开发依赖,不需要执行 `npm install` 或构建命令。 + +```bash +# 运行全部测试 +npm test + +# 运行基础一致性测试 +npm run conformance + +# 跑通本地端到端 Demo +npm run demo + +# 启动本地 Sandbox 服务 +npm run start +``` + +Demo 默认使用 `ATTESTED_CONFIRMATION`,通过 Mock 主体确认服务完成身份核验和确认。所有身份、信用值、签名密钥和授权记录都是测试数据。 + +Sandbox 对验证请求、DIRECT 主体确认、查询授权及生命周期变更采用失败关闭策略:必须注入可信公钥解析器并完成身份—公钥绑定和 Ed25519 验签;未配置解析器、无法解析密钥或证明无效时直接拒绝。Demo 使用进程内临时测试密钥和显式测试身份绑定,生产实现必须替换为可信密钥目录或等效信任来源。 + +更多运行说明见[快速开始](docs/quickstart.md)。Sandbox 的接口以非规范性 [OpenAPI](../../schemas/tsd-crd/reference-v1/openapi/openapi.yaml) 为准,创建申请使用 `POST /v1/association-applications`。 + +## 目录结构 + +```text +act-protocol/ +├── code/schemas/tsd-crd/reference-v1/ +│ ├── schemas/ # JSON Schema +│ ├── openapi/ # HTTP API 描述 +│ ├── examples/ # 标准报文示例 +│ └── test-vectors/ # 正常/异常固定向量 +└── code/samples/tsd-crd-reference/ + ├── src/ + │ ├── core/ # 协议对象、规则和状态机 + │ ├── application/ # 五个组件的用例编排 + │ ├── adapters/ # 内存和 Mock 适配器 + │ ├── http/ # 本地 Sandbox HTTP 入口 + │ ├── cli/ # CLI 和 Demo 入口 + │ └── conformance/ # 一致性测试 Runner + ├── test/ # 单元和集成测试 + ├── examples/ # 可运行示例 + └── docs/ # 架构、协议和安全说明 +``` + +架构和依赖边界见 [架构说明](docs/architecture.md)。 + +## 非生产边界 + +本仓库是协议参考实现,不是信用服务产品。 + +- 不要输入真实姓名、证件号、账号、手机号或信用数据。 +- 不要把测试密钥用于任何真实系统。 +- 不要把 Demo 验证结果用于授信、交易准入或支付决策。 +- 不要把内存存储、Mock 身份确认和固定映射规则用于生产。 +- 生产实现必须自行补充密钥管理、数据保护、审计、合规、可用性和风险控制。 + +详见[安全模型](docs/security-model.md)和仓库的[安全政策](../../../SECURITY.md)。 + +## 参与贡献 + +提交代码前请阅读仓库的[贡献指南](../../../CONTRIBUTING.md)。安全问题请按[安全政策](../../../SECURITY.md)私下报告。 + +## 许可证 + +本目录代码适用仓库 [Apache License 2.0](../../../LICENSE)。协议文本、项目名称和商标可能适用独立规则;代码许可证不自动授予商标使用权。 diff --git a/code/samples/tsd-crd-reference/docs/agent-key-possession-extension.md b/code/samples/tsd-crd-reference/docs/agent-key-possession-extension.md new file mode 100644 index 0000000..cc567a2 --- /dev/null +++ b/code/samples/tsd-crd-reference/docs/agent-key-possession-extension.md @@ -0,0 +1,70 @@ +# 可选 Agent 密钥持有证明扩展设计说明 + +## 定位 + +本扩展用于证明请求方持有某个 Agent 公钥对应的私钥。它是参考实现的可选增强,不属于: + +- ACT 2.1 TSD-CRD 信用关联子篇的必备对象。 +- P0 默认流程。 +- `ATTESTED_CONFIRMATION` 的前置条件。 +- `DIRECT_SIGNATURE` 的主体内层签名。 +- 基础一致性测试的通过条件。 + +当前仓库只保留本设计说明,尚未实现对应目录、Schema、API、CLI 和测试。默认 Demo、核心 API 和基础一致性 Runner 均不包含该能力。 + +## 能证明什么 + +扩展可以证明: + +> 当前请求方能够使用待登记公钥对应的私钥,对本次挑战内容生成有效签名。 + +扩展不能证明: + +- 持钥者是哪个真实主体。 +- 该主体同意建立信用关联。 +- 主体与 Agent 的关联角色有效。 +- Agent 软件或运行环境可信。 +- 当前请求在信用查询授权范围内。 + +## 一种参考流程 + +```text +提交 Agent 公钥和扩展上下文 + ↓ +服务端返回一次性挑战值和过期时间 + ↓ +Agent 私钥签署挑战载荷 + ↓ +服务端使用已保存公钥验签 + ↓ +原子消费挑战值并记录证明结果 +``` + +nonce 只是本扩展的一种防重放实现。协议核心使用“防重放要素”这一语义,不依赖该字段名或流程。 + +## 与主体确认组合 + +启用扩展时,Agent 持钥证明和主体确认仍是独立判断: + +```text +可选 Agent 持钥证明 + + +ATTESTED_CONFIRMATION 或 DIRECT_SIGNATURE + + +签发方外层签名 +``` + +扩展失败可以阻止采用该扩展的实现继续处理,但不能把扩展成功当作主体确认成功。 + +如果 Agent 相关密钥同时被用于 `DIRECT_SIGNATURE`,必须先证明该密钥与关联主体身份可信绑定,并明确允许主体确认用途。两种用途应使用不同签名域,避免签名跨用途复用。 + +## 一致性边界 + +基础 Runner 不检查该扩展。未来如实现,应单独维护: + +- Schema 和示例。 +- API 或 CLI。 +- 正常、篡改、过期和重放测试向量。 +- 版本和算法 Profile。 + +扩展测试报告必须与基础 TSD-CRD 一致性报告分开,避免使用者误以为该扩展是协议必选能力。 diff --git a/code/samples/tsd-crd-reference/docs/architecture.md b/code/samples/tsd-crd-reference/docs/architecture.md new file mode 100644 index 0000000..2e8025c --- /dev/null +++ b/code/samples/tsd-crd-reference/docs/architecture.md @@ -0,0 +1,110 @@ +# 架构说明 + +## 目标 + +架构服务于三个目标: + +1. 协议规则可以脱离 HTTP、CLI 和具体机构系统独立测试。 +2. 身份、信用、映射和存储能力可以替换。 +3. 同一批测试向量既能验证参考实现,也能验证第三方实现。 + +## 分层 + +```mermaid +flowchart TD + CLI["CLI / Demo"] --> APP["Application 用例层"] + HTTP["Sandbox HTTP"] --> APP + CONF["Conformance Runner"] --> APP + APP --> CORE["Core 协议内核"] + APP --> ADAPTERS["Adapters"] + CORE --> PROFILE["Reference Profile (non-normative)"] + VECTORS["Test Vectors"] --> CONF +``` + +依赖方向从入口指向用例和核心。`src/core` 不依赖 HTTP、CLI、文件系统或具体确认服务。 + +## 目录职责 + +| 目录 | 职责 | +| --- | --- | +| `../../schemas/tsd-crd/reference-v1` | 非规范性 Schema、OpenAPI、标准报文和测试向量 | +| `src/core` | 协议对象、不变量、签名投影、状态机、授权和验证规则 | +| `src/application` | ASC、MAP、LCM、AUTH、VER 用例编排 | +| `src/adapters` | 内存存储、Mock 身份确认、虚构信用和固定映射 | +| `src/http` | 本地 Sandbox HTTP 入口 | +| `src/cli` | Demo 和命令行入口 | +| `src/conformance` | 测试向量加载、执行和报告 | +| `../../schemas/tsd-crd/reference-v1/test-vectors` | 与实现无关的正常和异常向量 | + +## 五组件映射 + +| 组件 | 核心职责 | 外部能力 | +| --- | --- | --- | +| ASC | 申请校验、主体确认、关系核验、凭证签发 | 身份确认、关系证明、签发密钥 | +| MAP | 映射三要素、来源和规则版本校验 | 信用声明、映射策略 | +| LCM | 状态机、替换和状态有效性 | 状态存储、时钟 | +| AUTH | 授权范围、期限、频率和撤销 | 授权存储、计数器 | +| VER | 两级验证、原因码和最小披露 | 密钥解析、状态、信用和授权查询 | + +适配器只提供这些能力,不承载协议核心判断。 + +`createReferenceSuite` 提供两个密钥解析端口:`resolveRelyingPartyPublicKey` 和 `resolveSubjectPublicKey`。所有需要请求证明的操作都必须通过相应端口解析可信公钥并验签;未配置端口或无法解析时失败关闭。Demo 显式注入进程内测试身份目录,生产适配器必须接入可信目录。 + +## 默认 ATTESTED 流程 + +```mermaid +sequenceDiagram + participant Subject as 关联主体 + participant Service as 参考服务 + participant Attestation as Mock 确认服务 + participant Issuer as 凭证签发方 + + Subject->>Service: 创建信用关联申请 + Service-->>Subject: 返回待确认入口 + Subject->>Attestation: 完成身份核验并确认 + Attestation->>Service: 返回确认结果 + Service->>Service: 核验关系并生成关联信用映射 + Service->>Issuer: 请求签发 + Issuer-->>Service: 返回带外层签名的 ACTIVE 凭证 +``` + +默认流程不登记 Agent 公钥,不要求 nonce 挑战或 Agent 私钥签名。 + +## DIRECT 流程 + +```mermaid +sequenceDiagram + participant Subject as 关联主体 + participant Service as 参考服务 + participant Issuer as 凭证签发方 + + Subject->>Service: 创建信用关联申请 + Service-->>Subject: 返回待签名内容 + Subject->>Service: 提交主体内层签名 + Service->>Service: 解析可信主体公钥并验签 + Service->>Issuer: 请求签发 + Issuer-->>Service: 返回带外层签名的 ACTIVE 凭证 +``` + +待签名内容应覆盖协议要求的关联关键字段和防重放要素。具体字段、规范化和算法由 Reference Profile 定义。 + +DIRECT 待签名包显式携带原申请时间和防重放要素;二者与申请标识、主体、Agent、目的、范围、有效期和映射信息一起进入主体内层签名。Sandbox 不信任确认请求临时携带的裸公钥;Demo 通过显式测试身份目录解析主体公钥,生产实现必须替换为企业证书、可信密钥目录、身份核验结果或等效机制。 + +Agent 密钥持有证明目前只有[设计说明](agent-key-possession-extension.md),没有实现代码、接口或测试,不属于 P0。 + +## 数据和状态 + +P0 使用进程内存保存申请、凭证、授权、状态和验证记录。进程重启后数据可以丢失,这是 Sandbox 的明确边界。 + +生产实现应替换为具备事务、并发控制、审计、备份和访问控制的存储。核心字段变化不能覆盖原凭证,必须签发新凭证并保留前序引用。 + +## 零第三方依赖 + +P0 使用 Node.js `>= 22.18` 内置能力,不引入第三方包。这样可以降低供应链风险,并让示例更容易审阅。 + +新增依赖前需要说明: + +- 内置能力为什么无法满足。 +- 许可证和维护状态。 +- 供应链与体积影响。 +- 是否会影响浏览器或其他运行时的复用。 diff --git a/code/samples/tsd-crd-reference/docs/protocol-baseline.md b/code/samples/tsd-crd-reference/docs/protocol-baseline.md new file mode 100644 index 0000000..00ab525 --- /dev/null +++ b/code/samples/tsd-crd-reference/docs/protocol-baseline.md @@ -0,0 +1,153 @@ +# 协议基线 + +## 当前基线 + +本目录实现 ACT 2.1 信任服务域中的[信用关联子篇](../../../../docs/specification/trust-services.md)。 + +实现分为三层: + +| 层次 | 作用 | 是否规范性来源 | +| --- | --- | --- | +| ACT 2.1 TSD-CRD 正文 | 定义参与方、业务语义、对象、流程和约束 | 是 | +| [`reference-v1`](../../../schemas/tsd-crd/reference-v1/README.md) | 补充 JSON 字段、格式、算法和 HTTP 表达 | 否,除非实现明确采用该 Profile | +| 参考实现 | 提供可运行的 Sandbox、CLI 和测试 | 否 | + +发生冲突时,以 ACT 2.1 正文为准。实现或 Profile 中新增的字段和流程不能反向解释为协议要求,基础一致性测试通过也不构成 ACT 2.1 全量 Conformance 声明。 + +## 五个组件 + +### TSD-CRD-ASC:信用关联建立 + +定义信用关联申请、关联主体身份与关联关系核验、主体确认、关联信用映射和凭证签发。 + +建立关联至少需要: + +- 关联主体身份可验证。 +- Agent 具有可解析标识,以及可用于验证关联关系的身份凭证、控制材料或等效材料。 +- 关联主体能够提供与 Agent 的关系证明。 +- 申请明确主体、Agent、关联角色、目的、范围和必要限制。 +- 申请包含防重放要素。 + +协议不规定防重放要素必须是 nonce,也不要求 Agent 通过私钥挑战证明持钥。 + +### TSD-CRD-MAP:关联信用映射 + +定义关联主体信用声明如何映射为指定 Agent 的关联信用声明。 + +映射必须保留三个可追溯要素: + +1. 关联主体信用声明引用。 +2. 关联信用映射值。 +3. 映射规则标识和版本。 + +关联信用来源必须标记为 `ASSOCIATED_CREDIT`。它不是 Agent 自身信用、独立声誉、授信能力或业务资格。 + +### TSD-CRD-LCM:生命周期管理 + +凭证状态为: + +| 状态 | 含义 | +| --- | --- | +| `PENDING` | 申请已创建,但确认、映射或签发未完成 | +| `ACTIVE` | 凭证已生效,可在适用范围内用于验证 | +| `SUSPENDED` | 临时暂停,不得产生新的有效验证结果 | +| `REVOKED` | 已撤销,不能恢复 | +| `EXPIRED` | 已过期,不能再用于验证 | + +允许的主要流转: + +```text +PENDING -> ACTIVE | REVOKED | EXPIRED +ACTIVE -> SUSPENDED | REVOKED | EXPIRED +SUSPENDED -> ACTIVE | REVOKED | EXPIRED +``` + +`REVOKED` 和 `EXPIRED` 是终态。核心字段变化时不能覆盖原凭证,应签发新凭证并保留前序引用。 + +参考 Sandbox 在签发前把 `PENDING` 保存为关联申请的内部状态,因为此时还没有可验证的签名凭证。签发完成后才创建对外凭证和状态记录,初始对外状态为 `ACTIVE`。核心状态机仍保留协议规定的 `PENDING` 流转,生产实现可以为待签发对象分配可查询的凭证标识。 + +### TSD-CRD-AUTH:信用查询授权 + +信用查询授权只控制关联信用信息的查询和验证,不授权 Agent 进行交易、支付或履约。 + +P0 支持: + +- 逐次授权:每次关联信用信息验证单独确认。 +- 平台代理查询:主体预先授权指定代理在限定范围和期限内查询。 + +授权至少约束请求方、Agent、验证等级、业务目的、请求数据项和有效期。平台代理查询还应限制频率和结果用途。授权必须可验证、可撤销,且不能被转用于其他主体、Agent、目的或请求。 + +### TSD-CRD-VER:关联信用验证 + +验证分为两级: + +| 等级 | 检查内容 | 是否需要信用查询授权 | +| --- | --- | --- | +| 信用关联凭证验证 | 凭证签名、主体确认材料、关联范围和当前状态 | 否 | +| 关联信用信息验证 | 在凭证验证通过后,进一步核验信用声明、映射和授权 | 是 | + +验证结果为 `PASS`、`FAIL`、`INCONCLUSIVE` 或 `REVIEW_REQUIRED`。结果只是指定时点的协议验证结论,不是授信、支付或风险决策。 + +## 主体确认和签名 + +### ATTESTED_CONFIRMATION + +关联主体通过可信确认服务完成身份核验和交互确认。签发方根据确认结果签发信用关联凭证。 + +```text +主体身份核验和确认材料 + ↓ +签发方外层签名 + ↓ +信用关联凭证 +``` + +该模式的凭证只有签发方外层签名。协议不要求: + +- 关联主体拥有签名私钥。 +- Agent 登记公钥或 `keyId`。 +- Agent 使用私钥签署挑战值。 +- `AgentControlProof`。 + +可信确认服务的回调验签、HTTPS 或其他传输保护不计入凭证签名层数。 + +### DIRECT_SIGNATURE + +关联主体使用与自身身份可信绑定的私钥,对信用关联申请及确认内容做内层签名。签发方验证内层签名后,对最终凭证做外层签名。 + +```text +关联主体内层签名 + ↓ +签发方验证并做外层签名 + ↓ +信用关联凭证 +``` + +该模式具有两层签名: + +1. 主体内层签名,表达主体对指定 Agent、角色、目的、范围、有效期和映射信息的确认。 +2. 签发方外层签名,证明凭证由可信签发方签发并保护凭证核心字段。 + +主体密钥必须有独立信任依据,例如企业证书、可信密钥目录或其他身份—密钥绑定材料。 + +## 可选 Agent 持钥扩展 + +基于公钥、挑战值和 Agent 私钥的持钥证明可以作为未来扩展。它只回答“请求方是否持有某把 Agent 私钥”,不能证明: + +- 关联主体是谁。 +- 关联主体已经同意关联。 +- Agent 软件或运行环境可信。 +- Agent 获得了信用查询授权。 + +该扩展不属于 P0、默认 Demo 或基础协议一致性测试。当前仓库只提供[设计说明](agent-key-possession-extension.md),尚未实现扩展代码、接口和测试。 + +## 版本策略 + +`reference-v1` 表示本仓库 Reference Profile 的主版本,不等于协议正式版本号。 + +- 兼容性补充可以在同一 Profile 主版本内发布。 +- 字段语义、必备性、签名范围或状态规则的破坏性变化需要新主版本。 +- 每个测试向量应明确声明 Profile 版本。 +- 发布实现前必须记录所对应的协议公开版本。 + +当前迁入基线是独立 TSD-CRD 仓库提交 `fdf7006d97ae06645dce82400fac2dff964691f2`。已有 `camelCase` wire 字段、签名投影和固定向量保持不变;破坏性调整必须使用新的 Profile 主版本。 diff --git a/code/samples/tsd-crd-reference/docs/quickstart.md b/code/samples/tsd-crd-reference/docs/quickstart.md new file mode 100644 index 0000000..2700d6c --- /dev/null +++ b/code/samples/tsd-crd-reference/docs/quickstart.md @@ -0,0 +1,99 @@ +# 快速开始 + +## 环境 + +- Node.js `>= 22.18` +- npm + +项目零第三方依赖,不需要执行 `npm install`,也不需要构建。 + +```bash +node --version +npm test +``` + +## 运行 Demo + +```bash +npm run demo +``` + +默认 Demo 使用 `ATTESTED_CONFIRMATION`: + +1. 创建包含虚构主体和 Agent 的信用关联申请。 +2. Mock 确认服务完成主体身份核验和交互确认。 +3. 固定映射规则生成带 `ASSOCIATED_CREDIT` 标记的关联信用。 +4. 测试签发方对凭证做外层签名,凭证进入 `ACTIVE`。 +5. 执行凭证级验证。 +6. 演示缺少查询授权、完成授权和再次验证。 +7. 撤销凭证并确认它不能再得到新的 `PASS`。 + +默认 Demo 不生成 Agent 私钥,不执行 nonce 挑战。可选 Agent 持钥扩展不属于 P0。 + +## 启动 Sandbox + +```bash +npm run start +``` + +Sandbox 只监听本地开发接口,具体路径以非规范性 [OpenAPI](../../../schemas/tsd-crd/reference-v1/openapi/openapi.yaml) 为准。 + +创建一笔 ATTESTED 申请: + +```bash +curl -sS http://127.0.0.1:8787/v1/association-applications \ + -H 'content-type: application/json' \ + --data-binary @examples/association-application-sandbox.json +``` + +确认并签发凭证: + +```bash +curl -sS http://127.0.0.1:8787/v1/association-applications/association-application-demo-001/confirmations \ + -H 'content-type: application/json' \ + -d '{"confirmationMethod":"ATTESTED_CONFIRMATION"}' +``` + +常用入口: + +| 操作 | 方法和路径 | +| --- | --- | +| 创建信用关联申请 | `POST /v1/association-applications` | +| 提交主体确认 | `POST /v1/association-applications/{applicationId}/confirmations` | +| 查询凭证状态 | `GET /v1/association-credentials/{credentialId}/status` | +| 创建信用查询授权 | `POST /v1/credit-query-authorizations` | +| 执行验证 | `POST /v1/verifications` | + +所有数据保存在内存中。停止进程后,申请、凭证、授权和验证记录可以丢失。 + +示例文件中的签名占位值只用于展示报文结构,不能提交给受保护接口。Sandbox 对验证请求、DIRECT 主体确认、授权创建/撤销和凭证状态变更采用失败关闭策略;调用方必须通过 `resolveRelyingPartyPublicKey` 或 `resolveSubjectPublicKey` 注入可信身份—公钥绑定,否则请求会以 `REQUEST_PROOF_INVALID` 被拒绝。Demo 会生成临时测试密钥、显式注册测试身份并执行真实 Ed25519 签名。响应证明、状态证明和凭证签名也使用运行时临时测试密钥真实生成。 + +## 运行一致性测试 + +```bash +npm run conformance +``` + +Runner 读取: + +- [`reference-v1/test-vectors/valid/`](../../../schemas/tsd-crd/reference-v1/test-vectors/valid/) 中的正常向量。 +- [`reference-v1/test-vectors/invalid/`](../../../schemas/tsd-crd/reference-v1/test-vectors/invalid/) 中的篡改、过期、撤销和越权向量。 + +Agent 密钥持有扩展不计入基础一致性结果。 + +## 运行全部测试 + +```bash +npm test +``` + +测试失败时先检查: + +- Node.js 版本是否满足要求。 +- 测试向量声明的 Profile 版本是否匹配。 +- 本地时钟是否被测试固定时钟覆盖。 +- 是否误把 Agent 密钥持有证明等非 P0 设计当作核心要求。 + +## 数据安全 + +只使用仓库自带的虚构数据和测试密钥。不要把真实身份、账号、信用数据、私钥或内部服务地址放入请求、日志或测试向量。 diff --git a/code/samples/tsd-crd-reference/docs/security-model.md b/code/samples/tsd-crd-reference/docs/security-model.md new file mode 100644 index 0000000..759ebbf --- /dev/null +++ b/code/samples/tsd-crd-reference/docs/security-model.md @@ -0,0 +1,118 @@ +# 安全模型 + +## 适用范围 + +本文说明参考实现的信任边界和已知限制。它不替代生产系统的威胁建模、安全评审或合规评估。 + +## 参与方 + +| 参与方 | 主要职责 | +| --- | --- | +| 关联主体 | 提供身份和关系材料,确认信用关联及使用边界 | +| Agent | 被建立信用关联的智能体 | +| 信用服务方 | 核验主体身份、关联关系,提供信用和映射能力 | +| 确认服务 | 在 `ATTESTED_CONFIRMATION` 下完成主体身份核验和交互确认 | +| 凭证签发方 | 生成凭证并做外层签名 | +| 信用验证服务方 | 校验凭证、状态、授权、信用声明和映射 | +| 信用依赖方 | 请求并使用验证结果,自行作出业务判断 | + +这些角色可以由同一实体承担,也可以分开部署。角色合并不能省略相应校验。 + +## 必须分开的判断 + +以下问题不能用一个“已认证”状态代替: + +1. 关联主体是谁。 +2. 主体与 Agent 存在什么关系。 +3. 主体是否同意本次关联。 +4. 凭证是否由可信签发方签发且当前有效。 +5. 本次信用信息验证是否有有效授权。 + +可选的 Agent 密钥持有证明只回答“请求方是否持有某把私钥”,不能替代其中任何一项。 + +## 签名边界 + +### ATTESTED_CONFIRMATION + +凭证只有签发方外层签名。主体身份与确认意愿来自可信确认服务的确认材料。 + +实现必须确保确认结果绑定到同一笔信用关联申请,以及相同的主体、Agent、角色、目的、范围和有效期。确认结果不能跨申请复用。 + +### DIRECT_SIGNATURE + +凭证包含: + +1. 关联主体内层签名。 +2. 签发方外层签名。 + +服务端必须从可信来源解析主体公钥,不能只信任请求临时携带的裸公钥。签发方外层签名还应覆盖主体内层签名及其验证所需的公钥信息。 + +Sandbox 不直接信任 DIRECT 请求携带的公钥。主体公钥必须由 `resolveSubjectPublicKey` 按主体标识和 `keyId` 从显式信任目录解析;未配置解析器、无法解析或签名无效时失败关闭。Demo 的目录只绑定虚构测试身份,生产实现必须替换为企业证书、可信密钥目录、身份核验服务或等效材料。 + +### 状态 + +凭证当前状态独立维护,状态变化不应破坏原凭证签名。验证时必须查询当前状态,不能只看持有者提交的旧状态快照。 + +## 主要威胁和控制 + +| 威胁 | P0 控制 | +| --- | --- | +| 申请或确认结果重放 | 申请标识、防重放要素、过期时间和幂等校验 | +| 确认结果串用 | 绑定主体、Agent、目的、范围和申请标识 | +| 关联内容被篡改 | 主体确认材料、签名投影和签发方外层签名 | +| 使用已撤销或暂停凭证 | 每次验证查询当前状态 | +| 越权查询信用信息 | 校验请求方、Agent、目的、字段、期限和频率 | +| 过度披露 | 按验证等级、授权范围和请求字段裁剪响应 | +| 映射来源混淆 | 强制 `ASSOCIATED_CREDIT` 和映射三要素 | +| 原因码泄露信息 | 只返回协议需要的结论,不回显内部模型和原始数据 | +| 测试密钥误用 | 明确标记、仅使用固定公开测试密钥、禁止接入真实系统 | + +防重放要素可以是 nonce、一次性申请标识、时间窗或 Profile 定义的等效机制。协议不要求使用 Agent 私钥挑战。 + +## 最小披露 + +- 凭证级验证不查询或返回关联主体原始信用内容。 +- 关联信用信息验证只返回当前请求所需且已授权的数据。 +- 默认不返回主体原始身份信息、信用声明原文或内部映射依据。 +- 超出授权范围的字段应裁剪,不应因为请求中出现就返回。 + +## Mock 和虚构数据 + +P0 中以下能力都是模拟实现: + +- 主体身份和确认服务。 +- 关联关系证明。 +- 主体信用声明。 +- 关联信用映射规则。 +- 凭证和授权存储。 +- 签发与测试密钥。 + +Sandbox 对验证请求、DIRECT 主体确认、查询授权和状态变更证明均执行真实 Ed25519 验签,并要求 `resolveRelyingPartyPublicKey` 或 `resolveSubjectPublicKey` 提供可信身份—公钥绑定。缺少解析器或证明无效时不会降级为仅格式校验。Demo 使用临时测试密钥和显式虚构身份目录;生产实现不能复用这些测试信任材料。 + +它们只验证协议流程,不能证明真实身份、真实信用或生产安全性。 + +## 生产实现责任 + +生产部署至少要补充: + +- 真实身份服务和确认服务的信任建立。 +- 主体密钥、签发密钥和验证密钥的生命周期管理。 +- 认证、传输安全、访问控制、限流和抗拒绝服务。 +- 状态和授权的事务、并发、一致性和审计。 +- 个人信息保护、数据最小化、保留和删除策略。 +- 监控、告警、备份、灾备和事件响应。 +- 对映射规则、业务决策和监管要求的独立评审。 + +验证结果只能作为业务输入。它不直接构成交易准入、授信、支付批准或风险判断。 + +## 不提供的保证 + +本项目不能证明: + +- Agent 软件来自可信开发者。 +- Agent 运行环境没有被攻破。 +- Agent 的模型、工具或 Skill 未被篡改。 +- Agent 能正确理解和执行用户意图。 +- 关联主体信用等同于 Agent 自身信用。 + +这些问题需要软件供应链、运行环境证明、行为审计、授权治理和责任机制共同解决。 diff --git a/code/samples/tsd-crd-reference/examples/association-application-sandbox.json b/code/samples/tsd-crd-reference/examples/association-application-sandbox.json new file mode 100644 index 0000000..3ceacab --- /dev/null +++ b/code/samples/tsd-crd-reference/examples/association-application-sandbox.json @@ -0,0 +1,23 @@ +{ + "applicationId": "association-application-demo-001", + "messageVersion": "reference-v1", + "subjectId": "subject-demo-alice", + "agentId": "agent-demo-shopping", + "associationRole": "OPERATOR", + "relationshipEvidenceRefs": [ + "relationship-demo-alice-shopping-agent" + ], + "confirmationMethod": "ATTESTED_CONFIRMATION", + "issuerId": "issuer-demo-reference-suite", + "purpose": "DEMO_TRUST_CHECK", + "scope": [ + "shopping-assistant" + ], + "authorizationMode": "PER_REQUEST", + "requestedAt": "2026-01-01T00:00:00.000Z", + "validFrom": "2026-01-01T00:00:00.000Z", + "expiresAt": "2030-01-01T00:00:00.000Z", + "antiReplay": { + "nonce": "demo-association-nonce-001" + } +} diff --git a/code/samples/tsd-crd-reference/examples/end-to-end-demo.ts b/code/samples/tsd-crd-reference/examples/end-to-end-demo.ts new file mode 100644 index 0000000..e747894 --- /dev/null +++ b/code/samples/tsd-crd-reference/examples/end-to-end-demo.ts @@ -0,0 +1,163 @@ +import { pathToFileURL } from "node:url"; + +import { demoAssociationRequest } from "../src/adapters/mock-providers.ts"; +import { createReferenceSuite } from "../src/application/reference-suite.ts"; +import { + createAuthorization, + generateEd25519KeyPair, + signCanonical, + verificationRequestSigningPayload, + type Ed25519KeyPair, +} from "../src/core/index.ts"; + +const DEMO_NOW = "2026-08-12T00:00:00.000Z"; + +function verificationRequest( + input: Record, + relyingPartyKeys: Ed25519KeyPair, +): Record { + const requestId = String(input.requestId); + const unsigned = { + messageVersion: "reference-v1", + relyingPartyId: "relying-party-demo-shop", + businessContext: { demoRun: true }, + requestedAt: DEMO_NOW, + antiReplay: { nonce: `verification-nonce-${requestId}` }, + requestProof: { + signatureAlgorithm: "Ed25519", + keyId: relyingPartyKeys.keyId, + signatureValue: "PENDING", + }, + ...input, + }; + return { + ...unsigned, + requestProof: signCanonical( + verificationRequestSigningPayload(unsigned as never), + relyingPartyKeys.privateKey, + relyingPartyKeys.keyId, + ), + }; +} + +function signedSubjectRequest( + input: Record, + subjectKeys: Ed25519KeyPair, +): Record { + const payload = structuredClone(input); + return { + ...input, + requestProof: signCanonical(payload, subjectKeys.privateKey, subjectKeys.keyId), + }; +} + +export function runDemo(): Record { + const relyingPartyKeys = generateEd25519KeyPair("relying-party-demo-shop#key-1"); + const subjectKeys = generateEd25519KeyPair("subject-demo-alice#key-1"); + const suite = createReferenceSuite({ + now: () => DEMO_NOW, + resolveRelyingPartyPublicKey: (relyingPartyId, keyId) => + relyingPartyId === "relying-party-demo-shop" && keyId === relyingPartyKeys.keyId + ? relyingPartyKeys.publicKey + : undefined, + resolveSubjectPublicKey: (subjectId, keyId) => + subjectId === "subject-demo-alice" && keyId === subjectKeys.keyId + ? subjectKeys.publicKey + : undefined, + }); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const issued = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + const credential = issued.credential; + + const credentialVerification = suite.verify(verificationRequest({ + requestId: "verification-demo-credential-001", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "CREDENTIAL", + relyingPartyId: "relying-party-demo-shop", + purpose: credential.purpose, + requestedDataItems: ["credentialStatus"], + }, relyingPartyKeys)); + + const withoutAuthorization = suite.verify(verificationRequest({ + requestId: "verification-demo-credit-unauthorized-001", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "ASSOCIATED_CREDIT", + subjectCreditAssertionRef: credential.subjectCreditAssertionRef, + authorizationId: "authorization-demo-missing", + relyingPartyId: "relying-party-demo-shop", + purpose: credential.purpose, + requestedDataItems: ["associatedCreditValue", "mappingPolicy"], + }, relyingPartyKeys)); + + const authorization = suite.createQueryAuthorization(createAuthorization({ + authorizationId: "authorization-demo-per-request-001", + mode: "PER_REQUEST", + subjectId: credential.subjectId, + relyingPartyIds: ["relying-party-demo-shop"], + agentIds: [credential.agentId], + purpose: credential.purpose, + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "verification-demo-credit-authorized-001", + subjectPrivateKey: subjectKeys.privateKey, + subjectKeyId: subjectKeys.keyId, + }) as unknown as Record); + + const withAuthorization = suite.verify(verificationRequest({ + requestId: "verification-demo-credit-authorized-001", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "ASSOCIATED_CREDIT", + subjectCreditAssertionRef: credential.subjectCreditAssertionRef, + relyingPartyId: "relying-party-demo-shop", + purpose: credential.purpose, + requestedDataItems: ["associatedCreditValue", "mappingPolicy"], + authorizationId: authorization.authorizationId, + }, relyingPartyKeys)); + + const revokedStatus = suite.changeCredentialStatusFromRequest( + credential.credentialId, + "REVOKED", + signedSubjectRequest({ + requestId: "status-revoke-demo-001", + credentialId: credential.credentialId, + targetStatus: "REVOKED", + reasonCode: "SUBJECT_REQUEST", + requestedBy: credential.subjectId, + requestedAt: DEMO_NOW, + antiReplay: { nonce: "status-revoke-demo-nonce-001" }, + }, subjectKeys), + ); + const afterRevocation = suite.verify(verificationRequest({ + requestId: "verification-demo-after-revocation-001", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "CREDENTIAL", + relyingPartyId: "relying-party-demo-shop", + purpose: credential.purpose, + requestedDataItems: ["credentialStatus"], + }, relyingPartyKeys)); + + return { + profile: "reference-v1", + confirmationMethod: credential.confirmationMethod, + credentialSignatureLayers: 1, + application, + credential, + credentialVerification, + withoutAuthorization, + authorization, + withAuthorization, + revokedStatus, + afterRevocation, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.stdout.write(`${JSON.stringify(runDemo(), null, 2)}\n`); +} diff --git a/code/samples/tsd-crd-reference/package.json b/code/samples/tsd-crd-reference/package.json new file mode 100644 index 0000000..046a15d --- /dev/null +++ b/code/samples/tsd-crd-reference/package.json @@ -0,0 +1,28 @@ +{ + "name": "@act-protocol/tsd-crd-reference", + "version": "0.1.0", + "description": "Non-normative executable reference suite for ACT 2.1 TSD-CRD", + "type": "module", + "private": true, + "engines": { + "node": ">=22.18.0" + }, + "scripts": { + "start": "node src/http/server.ts", + "demo": "node src/cli/act-tsd-crd.ts demo run", + "conformance": "node src/cli/act-tsd-crd.ts conformance", + "test": "node --test test/*.test.ts", + "check": "npm test && npm run conformance" + }, + "bin": { + "act-tsd-crd": "./src/cli/act-tsd-crd.ts" + }, + "keywords": [ + "act", + "tsd-crd", + "credit-association", + "reference-implementation", + "conformance" + ], + "license": "Apache-2.0" +} diff --git a/code/samples/tsd-crd-reference/src/adapters/in-memory-store.ts b/code/samples/tsd-crd-reference/src/adapters/in-memory-store.ts new file mode 100644 index 0000000..bb57ddb --- /dev/null +++ b/code/samples/tsd-crd-reference/src/adapters/in-memory-store.ts @@ -0,0 +1,168 @@ +export type StoredAssociationRequest = { + applicationId: string; + status: "PENDING" | "ACTIVE" | "REVOKED" | "EXPIRED"; + antiReplay: Record; + createdAt: string; + updatedAt: string; + [key: string]: unknown; +}; + +export type StoredAuthorization = { + authorizationId: string; + status: "ACTIVE" | "REVOKED" | "EXPIRED"; + usageTimestamps: string[]; + [key: string]: unknown; +}; + +export type CredentialStatusRecord = { + credentialId: string; + status: "PENDING" | "ACTIVE" | "SUSPENDED" | "REVOKED" | "EXPIRED"; + statusVersion: number; + reasonCode?: string; + effectiveAt: string; + updatedAt: string; + statusProof: { + signatureAlgorithm: string; + signatureValue: string; + keyId?: string; + }; +}; + +const ALLOWED_STATUS_TRANSITIONS: Readonly +>> = { + PENDING: new Set(["ACTIVE", "REVOKED", "EXPIRED"]), + ACTIVE: new Set(["SUSPENDED", "REVOKED", "EXPIRED"]), + SUSPENDED: new Set(["ACTIVE", "REVOKED", "EXPIRED"]), + REVOKED: new Set(), + EXPIRED: new Set(), +}; + +export class InMemoryStore { + readonly associationRequests = new Map(); + readonly credentials = new Map>(); + readonly credentialStatuses = new Map(); + readonly authorizations = new Map(); + readonly verificationRecords = new Map>(); + readonly replayKeys = new Set(); + + consumeReplayKey(namespace: string, value: string): boolean { + const key = `${namespace}:${value}`; + if (this.replayKeys.has(key)) { + return false; + } + this.replayKeys.add(key); + return true; + } + + saveAssociationRequest(request: StoredAssociationRequest): void { + if (this.associationRequests.has(request.applicationId)) { + throw new Error(`Association request already exists: ${request.applicationId}`); + } + this.associationRequests.set(request.applicationId, structuredClone(request)); + } + + getAssociationRequest(applicationId: string): StoredAssociationRequest | undefined { + const value = this.associationRequests.get(applicationId); + return value ? structuredClone(value) : undefined; + } + + updateAssociationRequest(request: StoredAssociationRequest): void { + if (!this.associationRequests.has(request.applicationId)) { + throw new Error(`Association request not found: ${request.applicationId}`); + } + this.associationRequests.set(request.applicationId, structuredClone(request)); + } + + saveCredential(credential: Record): void { + const credentialId = String(credential.credentialId ?? ""); + if (!credentialId) { + throw new Error("credentialId is required"); + } + if (this.credentials.has(credentialId)) { + throw new Error(`Credential already exists and is immutable: ${credentialId}`); + } + this.credentials.set(credentialId, structuredClone(credential)); + } + + getCredential(credentialId: string): Record | undefined { + const value = this.credentials.get(credentialId); + return value ? structuredClone(value) : undefined; + } + + listCredentials(): Record[] { + return [...this.credentials.values()].map((value) => structuredClone(value)); + } + + saveCredentialStatus(status: CredentialStatusRecord): void { + if ( + status.statusProof.signatureAlgorithm !== "Ed25519" + || status.statusProof.signatureValue.trim() === "" + ) { + throw new Error("A non-empty Ed25519 statusProof is required"); + } + const current = this.credentialStatuses.get(status.credentialId); + if (current === undefined) { + if (status.statusVersion !== 1) { + throw new Error("Initial credential statusVersion must be 1"); + } + } else { + if (status.statusVersion !== current.statusVersion + 1) { + throw new Error("Credential statusVersion must increase by exactly one"); + } + if (!ALLOWED_STATUS_TRANSITIONS[current.status].has(status.status)) { + throw new Error( + `Invalid credential status transition: ${current.status} -> ${status.status}`, + ); + } + if (Date.parse(status.updatedAt) < Date.parse(current.updatedAt)) { + throw new Error("Credential status updatedAt must be monotonic"); + } + } + this.credentialStatuses.set(status.credentialId, structuredClone(status)); + } + + getCredentialStatus(credentialId: string): CredentialStatusRecord | undefined { + const value = this.credentialStatuses.get(credentialId); + return value ? structuredClone(value) : undefined; + } + + saveAuthorization(authorization: StoredAuthorization): void { + const current = this.authorizations.get(authorization.authorizationId); + if (current !== undefined) { + if (current.status !== "ACTIVE" && authorization.status !== current.status) { + throw new Error(`Authorization ${current.status} status is terminal`); + } + if (authorization.usageTimestamps.length < current.usageTimestamps.length) { + throw new Error("Authorization usage history must not shrink"); + } + } + this.authorizations.set( + authorization.authorizationId, + structuredClone(authorization), + ); + } + + getAuthorization(authorizationId: string): StoredAuthorization | undefined { + const value = this.authorizations.get(authorizationId); + return value ? structuredClone(value) : undefined; + } + + saveVerificationRecord(record: Record): void { + const recordId = String(record.verificationRecordId ?? ""); + if (!recordId) { + throw new Error("verificationRecordId is required"); + } + this.verificationRecords.set(recordId, structuredClone(record)); + } + + reset(): void { + this.associationRequests.clear(); + this.credentials.clear(); + this.credentialStatuses.clear(); + this.authorizations.clear(); + this.verificationRecords.clear(); + this.replayKeys.clear(); + } +} diff --git a/code/samples/tsd-crd-reference/src/adapters/mock-providers.ts b/code/samples/tsd-crd-reference/src/adapters/mock-providers.ts new file mode 100644 index 0000000..271d178 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/adapters/mock-providers.ts @@ -0,0 +1,167 @@ +export type IdentityVerification = { + verified: boolean; + subjectId: string; + assuranceLevel: string; + verifiedAt: string; +}; + +export type RelationshipVerification = { + verified: boolean; + verifierId: string; + evidenceRef: string; + assuranceProfile: string; + verifiedAt: string; +}; + +export type CreditAssertion = { + assertionRef: string; + subjectId: string; + valid: boolean; + lifecycleStatus: "ACTIVE" | "REVOKED" | "CORRECTED" | "REPLACED"; + level: string; + status: string; + validFrom: string; + validUntil: string; +}; + +export function isCreditAssertionCurrent( + assertion: CreditAssertion, + now: string, +): boolean { + const currentTime = Date.parse(now); + const validFrom = Date.parse(assertion.validFrom); + const validUntil = Date.parse(assertion.validUntil); + return assertion.valid + && assertion.lifecycleStatus === "ACTIVE" + && !Number.isNaN(currentTime) + && !Number.isNaN(validFrom) + && !Number.isNaN(validUntil) + && currentTime >= validFrom + && currentTime < validUntil; +} + +const DEMO_SUBJECTS = new Set(["subject-demo-alice", "subject-demo-company"]); + +const DEMO_RELATIONSHIPS = new Map([ + ["relationship-demo-alice-shopping-agent", { + subjectId: "subject-demo-alice", + agentId: "agent-demo-shopping", + role: "OPERATOR", + }], + ["relationship-demo-company-service-agent", { + subjectId: "subject-demo-company", + agentId: "agent-demo-service", + role: "CONTROLLER", + }], +]); + +const DEMO_ASSERTIONS = new Map([ + ["subject-demo-alice", { + assertionRef: "urn:acp:credit-assertion:subject-demo-alice:v1", + subjectId: "subject-demo-alice", + valid: true, + lifecycleStatus: "ACTIVE", + level: "A", + status: "GOOD_STANDING", + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2030-01-01T00:00:00.000Z", + }], + ["subject-demo-company", { + assertionRef: "urn:acp:credit-assertion:subject-demo-company:v1", + subjectId: "subject-demo-company", + valid: true, + lifecycleStatus: "ACTIVE", + level: "AA", + status: "GOOD_STANDING", + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2030-01-01T00:00:00.000Z", + }], +]); + +export class MockIdentityProvider { + verify(subjectId: string, now: string): IdentityVerification { + return { + verified: DEMO_SUBJECTS.has(subjectId), + subjectId, + assuranceLevel: "MOCK_SUBSTANTIAL", + verifiedAt: now, + }; + } +} + +export class MockRelationshipVerifier { + verify(input: { + subjectId: string; + agentId: string; + relationshipRole: string; + relationshipEvidenceRef: string; + now: string; + }): RelationshipVerification { + const relationship = DEMO_RELATIONSHIPS.get(input.relationshipEvidenceRef); + const verified = relationship !== undefined + && relationship.subjectId === input.subjectId + && relationship.agentId === input.agentId + && relationship.role === input.relationshipRole; + + return { + verified, + verifierId: "mock-relationship-registry", + evidenceRef: input.relationshipEvidenceRef, + assuranceProfile: "MOCK_REGISTRY_V1", + verifiedAt: input.now, + }; + } +} + +export class MockAttestationProvider { + confirm(input: { + associationRequestId: string; + subjectId: string; + agentId: string; + relationshipRole: string; + purpose: string; + scope: string[]; + now: string; + }): Record { + return { + confirmationMethod: "ATTESTED_CONFIRMATION", + confirmationProviderId: "mock-attestation-provider", + subjectId: input.subjectId, + associationRequestId: input.associationRequestId, + confirmedAgentId: input.agentId, + confirmedRole: input.relationshipRole, + confirmedPurpose: input.purpose, + confirmedScope: [...input.scope], + confirmationResultRef: `urn:acp:mock-attestation:${input.associationRequestId}`, + assuranceLevel: "MOCK_SUBSTANTIAL", + confirmedAt: input.now, + }; + } +} + +export class MockCreditAssertionProvider { + get(subjectId: string): CreditAssertion | undefined { + const assertion = DEMO_ASSERTIONS.get(subjectId); + return assertion ? structuredClone(assertion) : undefined; + } +} + +export function demoAssociationRequest(): Record { + return { + applicationId: "association-application-demo-001", + messageVersion: "reference-v1", + subjectId: "subject-demo-alice", + agentId: "agent-demo-shopping", + associationRole: "OPERATOR", + relationshipEvidenceRefs: ["relationship-demo-alice-shopping-agent"], + confirmationMethod: "ATTESTED_CONFIRMATION", + issuerId: "issuer-demo-reference-suite", + purpose: "DEMO_TRUST_CHECK", + scope: ["shopping-assistant"], + authorizationMode: "PER_REQUEST", + requestedAt: "2026-01-01T00:00:00.000Z", + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2030-01-01T00:00:00.000Z", + antiReplay: { nonce: "demo-association-nonce-001" }, + }; +} diff --git a/code/samples/tsd-crd-reference/src/application/reference-suite.ts b/code/samples/tsd-crd-reference/src/application/reference-suite.ts new file mode 100644 index 0000000..7f014df --- /dev/null +++ b/code/samples/tsd-crd-reference/src/application/reference-suite.ts @@ -0,0 +1,792 @@ +import { randomUUID } from "node:crypto"; +import { + createAuthorization, + createMappingTrace, + createVerificationRecord, + effectiveCredentialStatus, + evaluateAuthorization, + generateEd25519KeyPair, + issueAttestedCredential, + issueDirectCredentialFromConfirmation, + revokeAuthorization, + signCanonical, + subjectConfirmationPayload, + transitionStatus, + verifyAssociationCredential, + verifyAuthorizationProof, + verifyAssociatedCredit, + verifyCanonicalSignature, + verifyCredentialProof, + verifyVerificationRequest, + type AssociationCredential, + type CoreVerificationResult, + type PublicKeyReference, + type QueryAuthorization, +} from "../core/index.ts"; +import { + InMemoryStore, + type CredentialStatusRecord, + type StoredAssociationRequest, + type StoredAuthorization, +} from "../adapters/in-memory-store.ts"; +import { + MockAttestationProvider, + MockCreditAssertionProvider, + MockIdentityProvider, + MockRelationshipVerifier, + isCreditAssertionCurrent, +} from "../adapters/mock-providers.ts"; +import { + antiReplayValue, + asString, + asStringArray, + validateAssociationApplication, + validateAuthorizationRevocationRequest, + validateStatusChangeRequest, + validateVerificationRequest, +} from "./validation.ts"; + +export type ReferenceSuiteOptions = { + now?: () => string; + store?: InMemoryStore; + resolveRelyingPartyPublicKey?: ( + relyingPartyId: string, + keyId: string | undefined, + ) => PublicKeyReference | undefined; + resolveSubjectPublicKey?: ( + subjectId: string, + keyId: string | undefined, + ) => PublicKeyReference | undefined; +}; + +export type ReferenceSuite = ReturnType; + +function recordToCredential(value: Record): AssociationCredential { + return value as unknown as AssociationCredential; +} + +function authorizationToStored(value: QueryAuthorization): StoredAuthorization { + const protocolValue = structuredClone(value) as unknown as Record; + delete protocolValue.revokedAt; + return { + ...protocolValue, + authorizationId: value.authorizationId, + status: value.status as StoredAuthorization["status"], + usageTimestamps: [], + }; +} + +function authorizationFromStored(value: StoredAuthorization): QueryAuthorization { + const copy = structuredClone(value) as Record; + delete copy.usageTimestamps; + return copy as unknown as QueryAuthorization; +} + +function protocolAssociationApplication( + value: StoredAssociationRequest, +): Record { + const copy = structuredClone(value) as Record; + for (const field of [ + "status", + "createdAt", + "updatedAt", + "preparedCredential", + "identityVerification", + "relationshipVerification", + "attestation", + "credentialId", + ]) { + delete copy[field]; + } + return copy; +} + +function optionalRecord( + value: Record, + field: string, +): Record | undefined { + const candidate = value[field]; + if (candidate === undefined) return undefined; + if (candidate === null || Array.isArray(candidate) || typeof candidate !== "object") { + throw new TypeError(`${field} must be an object`); + } + return candidate as Record; +} + +function credentialInput(value: Record): { + credentialId: string; + inlineCredential?: AssociationCredential; +} { + const candidate = value.credential; + if (candidate === null || Array.isArray(candidate) || typeof candidate !== "object") { + throw new TypeError("credential must be a credential or credential reference"); + } + const record = candidate as Record; + const id = asString(record, "credentialId"); + return "issuerSignature" in record + ? { credentialId: id, inlineCredential: recordToCredential(record) } + : { credentialId: id }; +} + +function unsignedStatus( + status: CredentialStatusRecord | Omit, +): Omit { + const copy = structuredClone(status) as unknown as Record; + delete copy.statusProof; + return copy as Omit; +} + +function requestSigningPayload(value: Record): Record { + const copy = structuredClone(value); + delete copy.requestProof; + return copy; +} + +export function createReferenceSuite(options: ReferenceSuiteOptions = {}) { + const store = options.store ?? new InMemoryStore(); + const now = options.now ?? (() => new Date().toISOString()); + const identityProvider = new MockIdentityProvider(); + const relationshipVerifier = new MockRelationshipVerifier(); + const attestationProvider = new MockAttestationProvider(); + const creditAssertionProvider = new MockCreditAssertionProvider(); + const issuerKeys = generateEd25519KeyPair("issuer-demo-reference-suite#key-1"); + + function trustedSubjectPublicKey(subjectId: string, keyId: string | undefined) { + if (options.resolveSubjectPublicKey === undefined) { + throw new Error("REQUEST_PROOF_INVALID: trusted subject key resolver is required"); + } + const publicKey = options.resolveSubjectPublicKey(subjectId, keyId); + if (publicKey === undefined) { + throw new Error("REQUEST_PROOF_INVALID: subject key was not resolved"); + } + return publicKey; + } + + function credentialStatusProofIsValid(status: CredentialStatusRecord): boolean { + return verifyCanonicalSignature( + unsignedStatus(status), + status.statusProof, + issuerKeys.publicKey, + ); + } + + function hasActiveCredential(subjectId: string, agentId: string, currentTime: string): boolean { + return store.listCredentials().some((record) => { + const credential = recordToCredential(record); + if (credential.subjectId !== subjectId || credential.agentId !== agentId) return false; + if (!verifyCredentialProof({ credential, issuerPublicKey: issuerKeys.publicKey })) return false; + if (Date.parse(currentTime) < Date.parse(credential.validFrom)) return false; + const status = store.getCredentialStatus(credential.credentialId); + if (status === undefined || !credentialStatusProofIsValid(status)) return false; + return effectiveCredentialStatus( + { + credentialId: credential.credentialId, + status: status.status, + changedAt: status.updatedAt, + }, + credential.expiresAt, + currentTime, + ) === "ACTIVE"; + }); + } + + function buildCredentialDraft( + application: StoredAssociationRequest, + currentTime: string, + credentialId = `credential-${randomUUID()}`, + ) { + const subjectId = String(application.subjectId); + const agentId = String(application.agentId); + const associationRole = String(application.associationRole); + const relationshipEvidenceRefs = application.relationshipEvidenceRefs as string[]; + const purpose = String(application.purpose); + const scope = application.scope as string[]; + + const identity = identityProvider.verify(subjectId, currentTime); + if (!identity.verified) throw new Error("Subject identity verification failed"); + const relationship = relationshipVerifier.verify({ + subjectId, + agentId, + relationshipRole: associationRole, + relationshipEvidenceRef: relationshipEvidenceRefs[0], + now: currentTime, + }); + if (!relationship.verified) throw new Error("Relationship verification failed"); + + const creditAssertion = creditAssertionProvider.get(subjectId); + if (creditAssertion === undefined) throw new Error("CREDIT_ASSERTION_UNAVAILABLE"); + if (!isCreditAssertionCurrent(creditAssertion, currentTime)) { + throw new Error("CREDIT_ASSERTION_INVALID"); + } + const mapping = createMappingTrace({ + subjectCreditAssertionRef: creditAssertion.assertionRef, + associatedCreditValue: { + level: creditAssertion.level, + status: creditAssertion.status, + }, + mappingPolicy: { id: "mock-level-mapping", version: "1.0" }, + }); + + const credential = { + credentialId, + credentialVersion: "reference-v1", + associationApplicationId: application.applicationId, + agentId, + subjectId, + associationRole, + issuerId: String(application.issuerId), + relationshipEvidenceRef: relationship.evidenceRef, + confirmationMethod: String(application.confirmationMethod), + subjectCreditAssertionRef: mapping.subjectCreditAssertionRef, + associatedCreditValue: mapping.associatedCreditValue, + creditSource: "ASSOCIATED_CREDIT", + mappingPolicy: mapping.mappingPolicy, + confirmationStatement: `The subject confirms the ${associationRole} relationship for ${purpose}.`, + purpose, + scope, + ...(application.confirmationMethod === "DIRECT_SIGNATURE" + ? { + associationApplicationRequestedAt: String(application.requestedAt), + associationApplicationAntiReplay: structuredClone(application.antiReplay), + } + : {}), + issuedAt: currentTime, + validFrom: String(application.validFrom), + ...(application.expiresAt ? { expiresAt: String(application.expiresAt) } : {}), + statusQuery: { + uri: `/v1/association-credentials/${encodeURIComponent(credentialId)}/status`, + method: "GET", + }, + } as unknown as AssociationCredential; + return { credential, identity, relationship }; + } + + function createAssociationRequest(input: Record) { + const application = validateAssociationApplication(input); + const replayValue = antiReplayValue(application.antiReplay); + if (!store.consumeReplayKey("association-application", replayValue)) { + throw new Error("REPLAY_DETECTED: antiReplay value was already used"); + } + + const stored: StoredAssociationRequest = { + ...application, + status: "PENDING", + createdAt: now(), + updatedAt: now(), + }; + store.saveAssociationRequest(stored); + return structuredClone(stored); + } + + function prepareDirectConfirmation(applicationId: string) { + const application = store.getAssociationRequest(applicationId); + if (!application) throw new Error(`Association application not found: ${applicationId}`); + if (application.status !== "PENDING") { + throw new Error(`Association application is not PENDING: ${applicationId}`); + } + if (application.confirmationMethod !== "DIRECT_SIGNATURE") { + throw new Error("Preparation is only available for DIRECT_SIGNATURE"); + } + + const prepared = application.preparedCredential; + if (prepared !== null && typeof prepared === "object" && !Array.isArray(prepared)) { + return { + applicationId, + credential: structuredClone(prepared), + signingPayload: subjectConfirmationPayload(prepared as Record), + }; + } + + const currentTime = now(); + const context = buildCredentialDraft(application, currentTime); + store.updateAssociationRequest({ + ...application, + preparedCredential: context.credential as unknown as Record, + identityVerification: context.identity, + relationshipVerification: context.relationship, + updatedAt: currentTime, + }); + return { + applicationId, + credential: structuredClone(context.credential), + signingPayload: subjectConfirmationPayload( + context.credential as unknown as Record, + ), + }; + } + + function confirmAssociation( + applicationId: string, + input: Record, + ) { + const application = store.getAssociationRequest(applicationId); + if (!application) throw new Error(`Association application not found: ${applicationId}`); + if (application.status !== "PENDING") { + throw new Error(`Association application is not PENDING: ${applicationId}`); + } + + const currentTime = now(); + const confirmationMethod = String(application.confirmationMethod); + if (asString(input, "confirmationMethod") !== confirmationMethod) { + throw new Error("confirmationMethod must match the association application"); + } + + let credential: AssociationCredential; + let identity: unknown; + let relationship: unknown; + if (confirmationMethod === "ATTESTED_CONFIRMATION") { + const context = buildCredentialDraft(application, currentTime); + identity = context.identity; + relationship = context.relationship; + const attestation = attestationProvider.confirm({ + associationRequestId: applicationId, + subjectId: context.credential.subjectId, + agentId: context.credential.agentId, + relationshipRole: String(context.credential.associationRole), + purpose: context.credential.purpose, + scope: context.credential.scope, + now: currentTime, + }); + credential = issueAttestedCredential({ + credential: context.credential, + issuerPrivateKey: issuerKeys.privateKey, + }); + application.attestation = attestation; + } else if (confirmationMethod === "DIRECT_SIGNATURE") { + const prepared = application.preparedCredential; + if (prepared === null || Array.isArray(prepared) || typeof prepared !== "object") { + throw new Error("DIRECT_SIGNATURE must be prepared before confirmation"); + } + const subjectPublicKey = input.subjectPublicKey; + if (subjectPublicKey === null || Array.isArray(subjectPublicKey) + || typeof subjectPublicKey !== "object") { + throw new Error("subjectPublicKey is required for DIRECT_SIGNATURE"); + } + const submittedPublicKey = subjectPublicKey as unknown as PublicKeyReference; + const trustedPublicKey = trustedSubjectPublicKey( + String(application.subjectId), + submittedPublicKey.keyId, + ); + credential = issueDirectCredentialFromConfirmation({ + credential: prepared as unknown as AssociationCredential, + subjectPublicKey: trustedPublicKey, + subjectSignature: asString(input, "subjectSignature"), + subjectSignatureAlgorithm: asString(input, "subjectSignatureAlgorithm"), + issuerPrivateKey: issuerKeys.privateKey, + }); + identity = application.identityVerification; + relationship = application.relationshipVerification; + } else { + throw new Error(`Unsupported confirmation method: ${confirmationMethod}`); + } + + store.saveCredential(credential as unknown as Record); + const statusBase: Omit = { + credentialId: credential.credentialId, + status: "ACTIVE", + statusVersion: 1, + reasonCode: "ISSUED", + effectiveAt: currentTime, + updatedAt: currentTime, + }; + const status: CredentialStatusRecord = { + ...statusBase, + statusProof: signCanonical( + unsignedStatus(statusBase), + issuerKeys.privateKey, + issuerKeys.keyId, + ), + }; + store.saveCredentialStatus(status); + store.updateAssociationRequest({ + ...application, + status: "ACTIVE", + credentialId: credential.credentialId, + identityVerification: identity, + relationshipVerification: relationship, + updatedAt: currentTime, + }); + + return { applicationId, credential, status }; + } + + function getCredential(credentialId: string) { + return store.getCredential(credentialId); + } + + function getCredentialStatus(credentialId: string) { + const status = store.getCredentialStatus(credentialId); + if (status !== undefined && !credentialStatusProofIsValid(status)) { + throw new Error("ASSOCIATION_PROOF_INVALID: credential status proof is invalid"); + } + return status; + } + + function changeCredentialStatus( + credentialId: string, + targetStatus: CredentialStatusRecord["status"], + reasonCode: string, + ) { + const current = store.getCredentialStatus(credentialId); + if (!current) throw new Error(`Credential not found: ${credentialId}`); + if (!credentialStatusProofIsValid(current)) { + throw new Error("ASSOCIATION_PROOF_INVALID: current status proof is invalid"); + } + const transitioned = transitionStatus(current.status, targetStatus, now(), reasonCode); + const nextBase: Omit = { + credentialId, + status: transitioned.status, + statusVersion: current.statusVersion + 1, + reasonCode: transitioned.reasonCode ?? reasonCode, + effectiveAt: transitioned.changedAt, + updatedAt: transitioned.changedAt, + }; + const next: CredentialStatusRecord = { + ...nextBase, + statusProof: signCanonical( + unsignedStatus(nextBase), + issuerKeys.privateKey, + issuerKeys.keyId, + ), + }; + store.saveCredentialStatus(next); + return next; + } + + function changeCredentialStatusFromRequest( + credentialId: string, + targetStatus: CredentialStatusRecord["status"], + input: Record, + ) { + const request = validateStatusChangeRequest(input, now()); + if (request.credentialId !== credentialId) { + throw new Error("credentialId must match the path"); + } + if (request.targetStatus !== targetStatus) { + throw new Error("targetStatus must match the operation"); + } + const credentialRecord = store.getCredential(credentialId); + if (credentialRecord === undefined) { + throw new Error(`Credential not found: ${credentialId}`); + } + const credential = recordToCredential(credentialRecord); + if (request.requestedBy !== credential.subjectId) { + throw new Error("requestedBy must be the associated subject"); + } + const replayKey = [ + request.requestedBy, + request.requestId, + request.antiReplay.nonce ?? "", + request.antiReplay.idempotencyKey ?? "", + ].join(":"); + const publicKey = trustedSubjectPublicKey( + request.requestedBy, + request.requestProof.keyId, + ); + if (!verifyCanonicalSignature( + requestSigningPayload(request as unknown as Record), + request.requestProof, + publicKey, + )) { + throw new Error("REQUEST_PROOF_INVALID: status-change proof is invalid"); + } + if (!store.consumeReplayKey("credential-status-change", replayKey)) { + throw new Error("REPLAY_DETECTED: status-change request was already used"); + } + return changeCredentialStatus(credentialId, targetStatus, request.reasonCode); + } + + function createQueryAuthorization(input: Record) { + const frequencyLimit = optionalRecord(input, "frequencyLimit"); + const resultUseRestrictions = optionalRecord(input, "resultUseRestrictions"); + const suppliedProof = optionalRecord(input, "authorizationProof"); + if (suppliedProof === undefined) { + throw new Error("authorizationProof is required"); + } + const authorization = createAuthorization({ + authorizationId: asString(input, "authorizationId"), + mode: asString(input, "mode") as "PER_REQUEST" | "PLATFORM_DELEGATED", + subjectId: asString(input, "subjectId"), + relyingPartyIds: Array.isArray(input.relyingPartyIds) + ? asStringArray(input, "relyingPartyIds") + : [asString(input, "relyingPartyId")], + agentIds: asStringArray(input, "agentIds"), + authorizationVersion: input.authorizationVersion === undefined + ? "reference-v1" + : asString(input, "authorizationVersion") as "reference-v1", + verificationLevel: input.verificationLevel === undefined + ? "ASSOCIATED_CREDIT" + : asString(input, "verificationLevel") as "ASSOCIATED_CREDIT", + purpose: asString(input, "purpose"), + allowedDataItems: Array.isArray(input.allowedDataItems) + ? asStringArray(input, "allowedDataItems") + : asStringArray(input, "dataItems"), + validFrom: asString(input, "validFrom"), + expiresAt: typeof input.expiresAt === "string" + ? input.expiresAt + : asString(input, "validUntil"), + frequencyLimit: frequencyLimit === undefined + ? undefined + : { + maxRequestsPerWindow: Number(frequencyLimit.maxRequestsPerWindow), + windowDurationSeconds: Number(frequencyLimit.windowDurationSeconds), + }, + maxRequestsPerWindow: typeof input.maxRequestsPerWindow === "number" + ? input.maxRequestsPerWindow + : undefined, + windowDurationSeconds: typeof input.windowDurationSeconds === "number" + ? input.windowDurationSeconds + : undefined, + platformDelegateId: typeof input.platformDelegateId === "string" + ? input.platformDelegateId + : typeof input.platformAgentId === "string" + ? input.platformAgentId + : undefined, + boundRequestId: typeof input.boundRequestId === "string" + ? input.boundRequestId + : typeof input.requestId === "string" + ? input.requestId + : undefined, + resultUseRestrictions: resultUseRestrictions === undefined + ? undefined + : { + mayStore: Boolean(resultUseRestrictions.mayStore), + ...(typeof resultUseRestrictions.retentionSeconds === "number" + ? { retentionSeconds: resultUseRestrictions.retentionSeconds } + : {}), + mayTransfer: Boolean(resultUseRestrictions.mayTransfer), + }, + authorizationProof: suppliedProof as unknown as { + signatureAlgorithm: string; + signatureValue: string; + keyId?: string; + }, + }); + const proofKey = trustedSubjectPublicKey( + authorization.subjectId, + authorization.authorizationProof.keyId, + ); + if (!verifyAuthorizationProof(authorization, proofKey)) { + throw new Error("authorizationProof is invalid"); + } + const currentTime = now(); + for (const agentId of authorization.agentIds) { + if (!hasActiveCredential(authorization.subjectId, agentId, currentTime)) { + throw new Error( + `ACTIVE association credential is required for subject ${authorization.subjectId} and agent ${agentId}`, + ); + } + } + store.saveAuthorization(authorizationToStored(authorization)); + return authorization; + } + + function revokeQueryAuthorization(authorizationId: string) { + const current = store.getAuthorization(authorizationId); + if (!current) throw new Error(`Authorization not found: ${authorizationId}`); + const revoked = revokeAuthorization( + authorizationFromStored(current), + now(), + ); + store.saveAuthorization({ + ...authorizationToStored(revoked), + usageTimestamps: [...current.usageTimestamps], + }); + return authorizationFromStored(store.getAuthorization(authorizationId)!); + } + + function revokeQueryAuthorizationFromRequest( + authorizationId: string, + input: Record, + ) { + const request = validateAuthorizationRevocationRequest(input, now()); + if (request.authorizationId !== authorizationId) { + throw new Error("authorizationId must match the path"); + } + const current = store.getAuthorization(authorizationId); + if (!current) throw new Error(`Authorization not found: ${authorizationId}`); + if (String(current.subjectId) !== request.subjectId) { + throw new Error("subjectId does not match the authorization"); + } + const replayKey = [ + request.subjectId, + request.requestId, + request.antiReplay.nonce ?? "", + request.antiReplay.idempotencyKey ?? "", + ].join(":"); + const publicKey = trustedSubjectPublicKey( + request.subjectId, + request.requestProof.keyId, + ); + if (!verifyCanonicalSignature( + requestSigningPayload(request as unknown as Record), + request.requestProof, + publicKey, + )) { + throw new Error("REQUEST_PROOF_INVALID: revocation proof is invalid"); + } + if (!store.consumeReplayKey("authorization-revocation", replayKey)) { + throw new Error("REPLAY_DETECTED: authorization revocation was already used"); + } + return revokeQueryAuthorization(authorizationId); + } + + function verify(input: Record) { + const generatedAt = now(); + const request = validateVerificationRequest(input, generatedAt); + if (options.resolveRelyingPartyPublicKey === undefined) { + throw new Error("REQUEST_PROOF_INVALID: trusted relying-party key resolver is required"); + } + const publicKey = options.resolveRelyingPartyPublicKey( + request.relyingPartyId, + request.requestProof.keyId, + ); + if (publicKey === undefined) { + throw new Error("REQUEST_PROOF_INVALID: relying-party key was not resolved"); + } + const proofResult = verifyVerificationRequest({ + request, + relyingPartyPublicKey: publicKey, + now: generatedAt, + consumeAntiReplay: (key) => store.consumeReplayKey("verification", key), + }); + if (!proofResult.valid) { + throw new Error(`${proofResult.reasonCode}: verification request rejected`); + } + + const requestRecord = request as unknown as Record; + const { credentialId, inlineCredential } = credentialInput(requestRecord); + const credentialRecord = inlineCredential as unknown as Record + ?? store.getCredential(credentialId); + const status = store.getCredentialStatus(credentialId); + const verificationLevel = request.verificationLevel; + + if (!credentialRecord || !status) { + throw new Error(`Association credential not found: ${credentialId}`); + } + if (!credentialStatusProofIsValid(status)) { + throw new Error("ASSOCIATION_PROOF_INVALID: credential status proof is invalid"); + } + + const credential = recordToCredential(credentialRecord); + const baseInput = { + credential, + issuerPublicKey: issuerKeys.publicKey, + status: { + credentialId: status.credentialId, + status: status.status, + changedAt: status.updatedAt, + }, + agentId: request.agentId, + purpose: request.purpose, + requestedDataItems: request.requestedDataItems, + now: generatedAt, + }; + + let result: Record; + if (verificationLevel === "CREDENTIAL") { + result = verifyAssociationCredential(baseInput) as unknown as Record; + } else if (verificationLevel === "ASSOCIATED_CREDIT") { + if (request.subjectCreditAssertionRef !== credential.subjectCreditAssertionRef) { + throw new Error("subjectCreditAssertionRef does not match the credential"); + } + const authorizationId = request.authorizationId; + const authorization = authorizationId + ? store.getAuthorization(authorizationId) + : undefined; + if (authorization) { + const authorizationValue = authorizationFromStored(authorization); + const authorizationKey = trustedSubjectPublicKey( + authorizationValue.subjectId, + authorizationValue.authorizationProof.keyId, + ); + if (!verifyAuthorizationProof(authorizationValue, authorizationKey)) { + throw new Error("AUTHORIZATION_REQUIRED: authorization proof is invalid"); + } + const decision = evaluateAuthorization( + authorizationValue, + { + requestId: request.requestId, + relyingPartyId: request.relyingPartyId, + agentId: request.agentId, + verificationLevel: "ASSOCIATED_CREDIT", + purpose: request.purpose, + requestedDataItems: request.requestedDataItems, + ...(request.platformDelegateId === undefined + ? {} + : { platformDelegateId: request.platformDelegateId }), + }, + generatedAt, + authorization.usageTimestamps.map((usedAt) => ({ usedAt })), + ); + store.saveAuthorization({ + ...authorizationToStored(decision.authorization), + usageTimestamps: [...decision.runtimeState.usageTimestamps], + }); + if (!decision.allowed) { + result = { + result: "FAIL", + reasonCode: decision.reasonCode, + completedVerificationLevel: "CREDENTIAL", + credentialStatus: status.status, + scope: [...credential.scope], + }; + } else { + const creditAssertion = creditAssertionProvider.get(credential.subjectId); + result = verifyAssociatedCredit({ + ...baseInput, + authorization: decision.authorization, + creditAssertionAvailable: creditAssertion !== undefined, + creditAssertionValid: creditAssertion !== undefined + && creditAssertion.subjectId === credential.subjectId + && creditAssertion.assertionRef === credential.subjectCreditAssertionRef + && isCreditAssertionCurrent(creditAssertion, generatedAt), + mappingPolicySupported: credential.mappingPolicy.id === "mock-level-mapping", + }) as unknown as Record; + } + } else { + result = { + result: "FAIL", + reasonCode: "AUTHORIZATION_REQUIRED", + completedVerificationLevel: "CREDENTIAL", + credentialStatus: status.status, + scope: [...credential.scope], + }; + } + } else { + throw new Error(`Unsupported verificationLevel: ${verificationLevel}`); + } + + const record = createVerificationRecord({ + requestId: request.requestId, + agentId: credential.agentId, + verification: result as unknown as CoreVerificationResult, + generatedAt, + statusQuery: credential.statusQuery, + responsePrivateKey: issuerKeys.privateKey, + responseKeyId: issuerKeys.keyId, + }); + store.saveVerificationRecord(record as unknown as Record); + return record; + } + + function reset() { + store.reset(); + } + + return { + createAssociationRequest, + prepareDirectConfirmation, + confirmAssociation, + getCredential, + getCredentialStatus, + changeCredentialStatusFromRequest, + createQueryAuthorization, + revokeQueryAuthorizationFromRequest, + verify, + reset, + issuerPublicKey: issuerKeys.publicKey, + protocolAssociationApplication, + store, + }; +} diff --git a/code/samples/tsd-crd-reference/src/application/validation.ts b/code/samples/tsd-crd-reference/src/application/validation.ts new file mode 100644 index 0000000..af4aa69 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/application/validation.ts @@ -0,0 +1,303 @@ +import type { + AntiReplay, + SignatureProof, + VerificationRequest, +} from "../core/index.ts"; + +function requiredString( + value: Record, + field: string, +): string { + const candidate = value[field]; + if (typeof candidate !== "string" || candidate.trim() === "") { + throw new Error(`${field} must be a non-empty string`); + } + return candidate; +} + +function requiredRecord( + value: Record, + field: string, +): Record { + const candidate = value[field]; + if (candidate === null || Array.isArray(candidate) || typeof candidate !== "object") { + throw new Error(`${field} must be an object`); + } + return candidate as Record; +} + +function requiredStringArray( + value: Record, + field: string, +): string[] { + const candidate = value[field]; + if (!Array.isArray(candidate) || candidate.length === 0 + || candidate.some((entry) => typeof entry !== "string" || entry === "")) { + throw new Error(`${field} must be a non-empty string array`); + } + if (new Set(candidate).size !== candidate.length) { + throw new Error(`${field} must not contain duplicates`); + } + return [...candidate]; +} + +function requiredIsoDate(value: Record, field: string): string { + const candidate = requiredString(value, field); + if (Number.isNaN(Date.parse(candidate))) { + throw new Error(`${field} must be an RFC 3339 timestamp`); + } + return candidate; +} + +export type ValidAssociationApplication = { + applicationId: string; + messageVersion: "reference-v1"; + subjectId: string; + agentId: string; + associationRole: string; + relationshipEvidenceRefs: string[]; + confirmationMethod: "ATTESTED_CONFIRMATION" | "DIRECT_SIGNATURE"; + issuerId: string; + purpose: string; + scope: string[]; + authorizationMode: "PER_REQUEST" | "PLATFORM_DELEGATED"; + requestedAt: string; + validFrom: string; + expiresAt?: string; + antiReplay: Record; +}; + +export function validateAntiReplay(value: unknown): AntiReplay { + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new Error("antiReplay must be an object"); + } + const record = value as Record; + const allowed = new Set(["nonce", "idempotencyKey"]); + if (Object.keys(record).some((key) => !allowed.has(key))) { + throw new Error("antiReplay contains unsupported fields"); + } + const nonce = record.nonce; + const idempotencyKey = record.idempotencyKey; + if ((typeof nonce !== "string" || nonce.length < 16 || nonce.length > 256) + && (typeof idempotencyKey !== "string" || idempotencyKey.length < 8 + || idempotencyKey.length > 256)) { + throw new Error("antiReplay requires nonce or idempotencyKey"); + } + return { + ...(typeof nonce === "string" ? { nonce } : {}), + ...(typeof idempotencyKey === "string" ? { idempotencyKey } : {}), + }; +} + +export function validateProof(value: unknown, field = "requestProof"): SignatureProof { + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new Error(`${field} must be an object`); + } + const record = value as Record; + const allowed = new Set(["signatureAlgorithm", "signatureValue", "keyId"]); + if (Object.keys(record).some((key) => !allowed.has(key))) { + throw new Error(`${field} contains unsupported fields`); + } + const signatureAlgorithm = requiredString(record, "signatureAlgorithm"); + if (signatureAlgorithm !== "Ed25519") { + throw new Error(`${field}.signatureAlgorithm must be Ed25519`); + } + const signatureValue = requiredString(record, "signatureValue"); + return { + signatureAlgorithm, + signatureValue, + ...(record.keyId === undefined ? {} : { keyId: requiredString(record, "keyId") }), + }; +} + +function assertFresh(requestedAt: string, now: string, maxClockSkewSeconds = 300): void { + const requestTime = Date.parse(requestedAt); + const currentTime = Date.parse(now); + if ( + Number.isNaN(requestTime) + || Number.isNaN(currentTime) + || Math.abs(currentTime - requestTime) > maxClockSkewSeconds * 1_000 + ) { + throw new Error("REQUEST_EXPIRED: requestedAt is outside the allowed time window"); + } +} + +export function validateVerificationRequest( + input: Record, + now: string, +): VerificationRequest { + const messageVersion = requiredString(input, "messageVersion"); + if (messageVersion !== "reference-v1") { + throw new Error("messageVersion must be reference-v1"); + } + const verificationLevel = requiredString(input, "verificationLevel"); + if (verificationLevel !== "CREDENTIAL" && verificationLevel !== "ASSOCIATED_CREDIT") { + throw new Error("Unsupported verificationLevel"); + } + const requestedAt = requiredIsoDate(input, "requestedAt"); + assertFresh(requestedAt, now); + const businessContext = requiredRecord(input, "businessContext"); + if (Object.keys(businessContext).length === 0) { + throw new Error("businessContext must not be empty"); + } + if (Object.values(businessContext).some((entry) => ![ + "string", + "number", + "boolean", + ].includes(typeof entry))) { + throw new Error("businessContext values must be scalar"); + } + const credential = requiredRecord(input, "credential") as VerificationRequest["credential"]; + requiredString(credential as unknown as Record, "credentialId"); + const authorizationId = input.authorizationId === undefined + ? undefined + : requiredString(input, "authorizationId"); + const subjectCreditAssertionRef = input.subjectCreditAssertionRef === undefined + ? undefined + : requiredString(input, "subjectCreditAssertionRef"); + if ( + verificationLevel === "ASSOCIATED_CREDIT" + && (authorizationId === undefined || subjectCreditAssertionRef === undefined) + ) { + throw new Error( + "ASSOCIATED_CREDIT requires authorizationId and subjectCreditAssertionRef", + ); + } + return { + requestId: requiredString(input, "requestId"), + messageVersion, + relyingPartyId: requiredString(input, "relyingPartyId"), + agentId: requiredString(input, "agentId"), + verificationLevel, + ...(subjectCreditAssertionRef === undefined ? {} : { subjectCreditAssertionRef }), + ...(authorizationId === undefined ? {} : { authorizationId }), + ...(input.platformDelegateId === undefined + ? {} + : { platformDelegateId: requiredString(input, "platformDelegateId") }), + purpose: requiredString(input, "purpose"), + businessContext: structuredClone(businessContext) as VerificationRequest["businessContext"], + requestedDataItems: requiredStringArray(input, "requestedDataItems"), + credential: structuredClone(credential), + requestedAt, + antiReplay: validateAntiReplay(input.antiReplay), + requestProof: validateProof(input.requestProof), + }; +} + +export type ValidStatusChangeRequest = { + requestId: string; + credentialId: string; + targetStatus: "ACTIVE" | "SUSPENDED" | "REVOKED"; + reasonCode: string; + requestedBy: string; + requestedAt: string; + antiReplay: AntiReplay; + requestProof: SignatureProof; +}; + +export function validateStatusChangeRequest( + input: Record, + now: string, +): ValidStatusChangeRequest { + const targetStatus = requiredString(input, "targetStatus"); + if (!(["ACTIVE", "SUSPENDED", "REVOKED"] as const).includes(targetStatus as never)) { + throw new Error("Unsupported targetStatus"); + } + const requestedAt = requiredIsoDate(input, "requestedAt"); + assertFresh(requestedAt, now); + return { + requestId: requiredString(input, "requestId"), + credentialId: requiredString(input, "credentialId"), + targetStatus: targetStatus as ValidStatusChangeRequest["targetStatus"], + reasonCode: requiredString(input, "reasonCode"), + requestedBy: requiredString(input, "requestedBy"), + requestedAt, + antiReplay: validateAntiReplay(input.antiReplay), + requestProof: validateProof(input.requestProof), + }; +} + +export type ValidAuthorizationRevocationRequest = { + requestId: string; + authorizationId: string; + subjectId: string; + reasonCode: string; + requestedAt: string; + antiReplay: AntiReplay; + requestProof: SignatureProof; +}; + +export function validateAuthorizationRevocationRequest( + input: Record, + now: string, +): ValidAuthorizationRevocationRequest { + const requestedAt = requiredIsoDate(input, "requestedAt"); + assertFresh(requestedAt, now); + return { + requestId: requiredString(input, "requestId"), + authorizationId: requiredString(input, "authorizationId"), + subjectId: requiredString(input, "subjectId"), + reasonCode: requiredString(input, "reasonCode"), + requestedAt, + antiReplay: validateAntiReplay(input.antiReplay), + requestProof: validateProof(input.requestProof), + }; +} + +export function validateAssociationApplication( + input: Record, +): ValidAssociationApplication { + const messageVersion = requiredString(input, "messageVersion"); + if (messageVersion !== "reference-v1") { + throw new Error("messageVersion must be reference-v1"); + } + + const confirmationMethod = requiredString(input, "confirmationMethod"); + if (confirmationMethod !== "ATTESTED_CONFIRMATION" + && confirmationMethod !== "DIRECT_SIGNATURE") { + throw new Error("Unsupported confirmationMethod"); + } + + const authorizationMode = requiredString(input, "authorizationMode"); + if (authorizationMode !== "PER_REQUEST" + && authorizationMode !== "PLATFORM_DELEGATED") { + throw new Error("Unsupported authorizationMode"); + } + + const antiReplay = validateAntiReplay(input.antiReplay); + + const expiresAt = input.expiresAt === undefined + ? undefined + : requiredIsoDate(input, "expiresAt"); + + return { + applicationId: requiredString(input, "applicationId"), + messageVersion: "reference-v1", + subjectId: requiredString(input, "subjectId"), + agentId: requiredString(input, "agentId"), + associationRole: requiredString(input, "associationRole"), + relationshipEvidenceRefs: requiredStringArray(input, "relationshipEvidenceRefs"), + confirmationMethod, + issuerId: requiredString(input, "issuerId"), + purpose: requiredString(input, "purpose"), + scope: requiredStringArray(input, "scope"), + authorizationMode, + requestedAt: requiredIsoDate(input, "requestedAt"), + validFrom: requiredIsoDate(input, "validFrom"), + ...(expiresAt ? { expiresAt } : {}), + antiReplay: structuredClone(antiReplay) as Record, + }; +} + +export function antiReplayValue(antiReplay: Record): string { + if (typeof antiReplay.nonce === "string") return `nonce:${antiReplay.nonce}`; + return `idempotency:${String(antiReplay.idempotencyKey)}`; +} + +export function asString(value: Record, field: string): string { + return requiredString(value, field); +} + +export function asStringArray(value: Record, field: string): string[] { + return requiredStringArray(value, field); +} diff --git a/code/samples/tsd-crd-reference/src/cli/act-tsd-crd.ts b/code/samples/tsd-crd-reference/src/cli/act-tsd-crd.ts new file mode 100644 index 0000000..cf08a79 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/cli/act-tsd-crd.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { runDemo } from "../../examples/end-to-end-demo.ts"; +import { runConformance } from "../conformance/runner.ts"; +import { startSandboxServer } from "../http/server.ts"; + +function print(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +async function readInput(argument: string | undefined): Promise> { + if (!argument) { + throw new Error("A JSON object or @file path is required"); + } + const source = argument.startsWith("@") + ? await readFile(argument.slice(1), "utf8") + : argument; + const value: unknown = JSON.parse(source); + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new Error("Input must be a JSON object"); + } + return value as Record; +} + +async function request( + method: string, + path: string, + body?: Record, +): Promise { + const baseUrl = process.env.ACT_TSD_CRD_BASE_URL ?? "http://127.0.0.1:8787"; + const response = await fetch(new URL(path, baseUrl), { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const payload: unknown = await response.json(); + if (!response.ok) { + throw new Error(`Sandbox returned HTTP ${response.status}: ${JSON.stringify(payload)}`); + } + return payload; +} + +function usage(): string { + return [ + "Usage:", + " act-tsd-crd demo run", + " act-tsd-crd conformance", + " act-tsd-crd serve [port]", + " act-tsd-crd association request '' | @file", + " act-tsd-crd association prepare ", + " act-tsd-crd association confirm-attested ", + " act-tsd-crd association confirm-direct '' | @file", + " act-tsd-crd credential status ", + " act-tsd-crd credential revoke '' | @file", + " act-tsd-crd authorization create '' | @file", + " act-tsd-crd authorization revoke '' | @file", + " act-tsd-crd verify '' | @file", + "", + "Set ACT_TSD_CRD_BASE_URL to call a running Sandbox. Default: http://127.0.0.1:8787", + ].join("\n"); +} + +async function main(args: string[]): Promise { + const [group, action, ...rest] = args; + + if (group === "demo" && action === "run") { + return print(runDemo()); + } + if (group === "conformance" && action === undefined) { + const report = runConformance(); + print(report); + if (!report.passed) process.exitCode = 1; + return; + } + if (group === "serve") { + const port = action ? Number(action) : undefined; + if (port !== undefined && !Number.isInteger(port)) { + throw new Error(`Invalid port: ${action}`); + } + startSandboxServer({ port }); + return; + } + if (group === "association" && action === "request") { + return print(await request("POST", "/v1/association-applications", await readInput(rest[0]))); + } + if (group === "association" && action === "prepare") { + if (!rest[0]) throw new Error("request-id is required"); + return print(await request( + "POST", + `/v1/association-applications/${encodeURIComponent(rest[0])}/preparations`, + {}, + )); + } + if (group === "association" && action === "confirm-attested") { + if (!rest[0]) throw new Error("request-id is required"); + return print(await request( + "POST", + `/v1/association-applications/${encodeURIComponent(rest[0])}/confirmations`, + { confirmationMethod: "ATTESTED_CONFIRMATION" }, + )); + } + if (group === "association" && action === "confirm-direct") { + if (!rest[0]) throw new Error("request-id is required"); + return print(await request( + "POST", + `/v1/association-applications/${encodeURIComponent(rest[0])}/confirmations`, + await readInput(rest[1]), + )); + } + if (group === "credential" && action === "status") { + if (!rest[0]) throw new Error("credential-id is required"); + return print(await request( + "GET", + `/v1/association-credentials/${encodeURIComponent(rest[0])}/status`, + )); + } + if (group === "credential" && action === "revoke") { + if (!rest[0]) throw new Error("credential-id is required"); + return print(await request( + "POST", + `/v1/association-credentials/${encodeURIComponent(rest[0])}/revocations`, + await readInput(rest[1]), + )); + } + if (group === "authorization" && action === "create") { + return print(await request("POST", "/v1/credit-query-authorizations", await readInput(rest[0]))); + } + if (group === "authorization" && action === "revoke") { + if (!rest[0]) throw new Error("authorization-id is required"); + return print(await request( + "POST", + `/v1/credit-query-authorizations/${encodeURIComponent(rest[0])}/revocations`, + await readInput(rest[1]), + )); + } + if (group === "verify") { + const input = action ?? rest[0]; + return print(await request("POST", "/v1/verifications", await readInput(input))); + } + + process.stdout.write(`${usage()}\n`); + if (group && group !== "help" && group !== "--help" && group !== "-h") { + process.exitCode = 1; + } +} + +main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/code/samples/tsd-crd-reference/src/conformance/runner.ts b/code/samples/tsd-crd-reference/src/conformance/runner.ts new file mode 100644 index 0000000..5151f3e --- /dev/null +++ b/code/samples/tsd-crd-reference/src/conformance/runner.ts @@ -0,0 +1,319 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { verify as verifyEd25519 } from "node:crypto"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { demoAssociationRequest } from "../adapters/mock-providers.ts"; +import { validateAssociationApplication } from "../application/validation.ts"; +import { + canTransitionStatus, + createAuthorization, + createMappingTrace, + generateEd25519KeyPair, + issueAttestedCredential, + issueDirectCredential, + REASON_CODES, + VERIFICATION_RESULTS, + verifyCredentialProof, + type AssociationCredential, +} from "../core/index.ts"; + +export type ConformanceCheck = { + id: string; + passed: boolean; + detail?: string; +}; + +export type ConformanceReport = { + profile: "reference-v1"; + passed: boolean; + total: number; + passedCount: number; + failedCount: number; + checks: ConformanceCheck[]; +}; + +function check(id: string, action: () => void): ConformanceCheck { + try { + action(); + return { id, passed: true }; + } catch (error) { + return { + id, + passed: false, + detail: error instanceof Error ? error.message : String(error), + }; + } +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function credentialDraft(): AssociationCredential { + return { + credentialId: "credential-conformance-001", + credentialVersion: "reference-v1", + associationApplicationId: "application-conformance-001", + agentId: "agent-conformance-001", + subjectId: "subject-conformance-001", + associationRole: "OPERATOR", + issuerId: "issuer-conformance-001", + relationshipEvidenceRef: "urn:acp:relationship:conformance-001", + confirmationMethod: "ATTESTED_CONFIRMATION", + subjectCreditAssertionRef: "urn:acp:credit-assertion:conformance-001", + associatedCreditValue: { level: "A" }, + creditSource: "ASSOCIATED_CREDIT", + mappingPolicy: { id: "conformance-mapping", version: "1.0" }, + confirmationStatement: "The subject confirms the relationship.", + purpose: "CONFORMANCE_TEST", + scope: ["test"], + associationApplicationRequestedAt: "2026-08-12T00:00:00.000Z", + associationApplicationAntiReplay: { nonce: "conformance-direct-nonce-001" }, + issuedAt: "2026-08-12T00:00:00.000Z", + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2030-01-01T00:00:00.000Z", + statusQuery: { uri: "/v1/association-credentials/credential-conformance-001/status" }, + issuerSignature: "", + issuerSignatureAlgorithm: "", + }; +} + +type JsonVector = { + kind: "valid" | "invalid"; + name: string; + value: unknown; +}; + +type Ed25519Vector = { + algorithm: "Ed25519"; + canonicalPayload: string; + publicKey: string; + signature: string; + expectedValid: boolean; +}; + +type ReasonCodeVector = { + operation: "verificationReasonCode"; + expectedResult: string; + expectedReasonCode: string; + expectedValid: true; +}; + +function loadJsonVectors(): JsonVector[] { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const vectorsRoot = join( + currentDir, + "..", + "..", + "..", + "..", + "schemas", + "tsd-crd", + "reference-v1", + "test-vectors", + ); + const vectors: JsonVector[] = []; + for (const kind of ["valid", "invalid"] as const) { + const directory = join(vectorsRoot, kind); + let files: string[] = []; + try { + files = readdirSync(directory).filter((file) => file.endsWith(".json")); + } catch { + continue; + } + for (const file of files) { + vectors.push({ + kind, + name: `${kind}/${file}`, + value: JSON.parse(readFileSync(join(directory, file), "utf8")), + }); + } + } + return vectors; +} + +function isRecord(value: unknown): value is Record { + return value !== null && !Array.isArray(value) && typeof value === "object"; +} + +function vectorById(vectors: JsonVector[], id: string): JsonVector { + const vector = vectors.find( + ({ value }) => isRecord(value) && value.id === id, + ); + assert(vector !== undefined, `Missing test vector: ${id}`); + return vector; +} + +function asEd25519Vector(vector: JsonVector): Ed25519Vector { + assert(isRecord(vector.value), `${vector.name} must contain a JSON object`); + const value = vector.value; + assert(value.algorithm === "Ed25519", `${vector.name} must use Ed25519`); + assert( + typeof value.canonicalPayload === "string", + `${vector.name} canonicalPayload must be a string`, + ); + assert(typeof value.publicKey === "string", `${vector.name} publicKey must be a string`); + assert(typeof value.signature === "string", `${vector.name} signature must be a string`); + assert( + typeof value.expectedValid === "boolean", + `${vector.name} expectedValid must be a boolean`, + ); + return value as Ed25519Vector; +} + +function executeEd25519Vector(vector: JsonVector): boolean { + const value = asEd25519Vector(vector); + try { + return verifyEd25519( + null, + Buffer.from(value.canonicalPayload, "utf8"), + value.publicKey, + Buffer.from(value.signature, "base64url"), + ); + } catch { + return false; + } +} + +function reasonCodeVectors(vectors: JsonVector[]): ReasonCodeVector[] { + return vectors.flatMap(({ value, name }) => { + if (!isRecord(value) || value.operation !== "verificationReasonCode") return []; + assert( + typeof value.expectedReasonCode === "string", + `${name} expectedReasonCode must be a string`, + ); + assert( + typeof value.expectedResult === "string", + `${name} expectedResult must be a string`, + ); + assert(value.expectedValid === true, `${name} must be a valid outcome vector`); + return [value as ReasonCodeVector]; + }); +} + +export function runConformance(): ConformanceReport { + const issuerKeys = generateEd25519KeyPair("issuer-conformance#key-1"); + const subjectKeys = generateEd25519KeyPair("subject-conformance#key-1"); + const vectors = loadJsonVectors(); + const checks: ConformanceCheck[] = [ + check("ASC.APPLICATION.BASELINE", () => { + const value = validateAssociationApplication(demoAssociationRequest()); + assert(value.messageVersion === "reference-v1", "Wrong profile version"); + }), + check("MAP.TRACEABILITY.TRIAD", () => { + const mapping = createMappingTrace({ + subjectCreditAssertionRef: "urn:acp:assertion:1", + associatedCreditValue: { level: "A" }, + mappingPolicy: { id: "policy-1", version: "1" }, + }); + assert(mapping.creditSource === "ASSOCIATED_CREDIT", "Wrong credit source"); + }), + check("ASC.ATTESTED.ONE_SIGNATURE_LAYER", () => { + const credential = issueAttestedCredential({ + credential: credentialDraft(), + issuerPrivateKey: issuerKeys.privateKey, + }); + assert(credential.issuerSignature.length > 0, "Issuer signature missing"); + assert(credential.subjectSignature === undefined, "Subject signature must be absent"); + assert(credential.subjectPublicKey === undefined, "Subject public key must be absent"); + assert(verifyCredentialProof({ credential, issuerPublicKey: issuerKeys.publicKey }), "Proof invalid"); + }), + check("ASC.DIRECT.TWO_SIGNATURE_LAYERS", () => { + const credential = issueDirectCredential({ + credential: credentialDraft(), + subjectPrivateKey: subjectKeys.privateKey, + subjectPublicKey: subjectKeys.publicKey, + issuerPrivateKey: issuerKeys.privateKey, + }); + assert(credential.subjectSignature?.length, "Subject signature missing"); + assert(credential.issuerSignature.length > 0, "Issuer signature missing"); + assert(verifyCredentialProof({ credential, issuerPublicKey: issuerKeys.publicKey }), "Proof invalid"); + }), + check("LCM.TERMINAL.REVOKED", () => { + assert(!canTransitionStatus("REVOKED", "ACTIVE"), "REVOKED must be terminal"); + }), + check("LCM.TERMINAL.EXPIRED", () => { + assert(!canTransitionStatus("EXPIRED", "ACTIVE"), "EXPIRED must be terminal"); + }), + check("AUTH.PER_REQUEST.BOUND_REQUEST", () => { + const authorizationKeys = generateEd25519KeyPair( + "subject-conformance#authorization-key-1", + ); + const authorization = createAuthorization({ + authorizationId: "authorization-conformance-001", + mode: "PER_REQUEST", + subjectId: "subject-conformance-001", + relyingPartyIds: ["rp-conformance-001"], + agentIds: ["agent-conformance-001"], + purpose: "CONFORMANCE_TEST", + allowedDataItems: ["associatedCreditValue"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2030-01-01T00:00:00.000Z", + boundRequestId: "verification-conformance-001", + subjectPrivateKey: authorizationKeys.privateKey, + subjectKeyId: authorizationKeys.keyId, + }); + assert(authorization.boundRequestId === "verification-conformance-001", "Request binding missing"); + }), + check("VECTORS.JSON.PARSE", () => { + assert(vectors.length > 0, "No JSON test vectors found"); + }), + check("VECTORS.EXPECTED_VALID.DIRECTORY_SEMANTICS", () => { + let declarations = 0; + for (const vector of vectors) { + assert(isRecord(vector.value), `${vector.name} must contain a JSON object`); + if (!("expectedValid" in vector.value)) continue; + declarations += 1; + assert( + typeof vector.value.expectedValid === "boolean", + `${vector.name} expectedValid must be a boolean`, + ); + const expectedForDirectory = vector.kind === "valid"; + assert( + vector.value.expectedValid === expectedForDirectory, + `${vector.name} declares expectedValid=${String(vector.value.expectedValid)} ` + + `but is stored under ${vector.kind}/`, + ); + } + assert(declarations > 0, "No expectedValid declarations found"); + }), + check("VECTORS.REASON_CODES.COMPLETE", () => { + const reasonVectors = reasonCodeVectors(vectors); + const covered = new Set(reasonVectors.map((vector) => vector.expectedReasonCode)); + const missing = REASON_CODES.filter((reasonCode) => !covered.has(reasonCode)); + const unknown = [...covered].filter( + (reasonCode) => !REASON_CODES.includes(reasonCode as never), + ); + assert(missing.length === 0, `Missing reason-code vectors: ${missing.join(", ")}`); + assert(unknown.length === 0, `Unknown reason codes in vectors: ${unknown.join(", ")}`); + assert( + reasonVectors.every((vector) => + VERIFICATION_RESULTS.includes(vector.expectedResult as never)), + "Reason-code vectors contain an unsupported verification result", + ); + }), + check("CRYPTO.ED25519.FIXED.VALID", () => { + const vector = vectorById(vectors, "canonical-ed25519-verification"); + const value = asEd25519Vector(vector); + assert(value.expectedValid, `${vector.name} must expect a valid signature`); + assert(executeEd25519Vector(vector), "Fixed Ed25519 signature did not verify"); + }), + check("CRYPTO.ED25519.FIXED.TAMPERED", () => { + const vector = vectorById(vectors, "canonical-ed25519-tampered"); + const value = asEd25519Vector(vector); + assert(!value.expectedValid, `${vector.name} must expect an invalid signature`); + assert(!executeEd25519Vector(vector), "Tampered Ed25519 payload unexpectedly verified"); + }), + ]; + + const passedCount = checks.filter((entry) => entry.passed).length; + return { + profile: "reference-v1", + passed: passedCount === checks.length, + total: checks.length, + passedCount, + failedCount: checks.length - passedCount, + checks, + }; +} diff --git a/code/samples/tsd-crd-reference/src/core/association.ts b/code/samples/tsd-crd-reference/src/core/association.ts new file mode 100644 index 0000000..9ed1829 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/association.ts @@ -0,0 +1,357 @@ +import type { + AssociationCredential, + PublicKeyReference, + SignatureProof, +} from "./types.ts"; +import { + signCanonical, + type KeyMaterial, + verifyCanonicalSignature, +} from "./crypto.ts"; +import { mappingTraceFromCredential, validateMappingTrace } from "./mapping.ts"; + +type CredentialDraft = Omit< + AssociationCredential, + | "issuerSignature" + | "issuerSignatureAlgorithm" + | "subjectSignature" + | "subjectSignatureAlgorithm" + | "subjectPublicKey" + | "confirmationMethod" +> & Record; + +export interface IssueAttestedCredentialInput { + credential: CredentialDraft | AssociationCredential; + issuerPrivateKey: KeyMaterial; + issuerKeyId?: string; +} + +export interface IssueDirectCredentialInput { + credential: CredentialDraft | AssociationCredential; + subjectPrivateKey: KeyMaterial; + subjectPublicKey?: PublicKeyReference; + subjectKeyId?: string; + issuerPrivateKey: KeyMaterial; + issuerKeyId?: string; +} + +export interface IssueDirectCredentialFromConfirmationInput { + credential: CredentialDraft | AssociationCredential; + subjectPublicKey: PublicKeyReference; + subjectSignature: string; + subjectSignatureAlgorithm: string; + issuerPrivateKey: KeyMaterial; + issuerKeyId?: string; +} + +function withoutKeys>( + value: T, + keys: readonly string[], +): Record { + const copy = structuredClone(value); + for (const key of keys) delete copy[key]; + return copy; +} + +function assertCredentialBase(value: Record): void { + const required = [ + "credentialId", + "credentialVersion", + "associationApplicationId", + "agentId", + "subjectId", + "issuerId", + "relationshipEvidenceRef", + "subjectCreditAssertionRef", + "associatedCreditValue", + "creditSource", + "mappingPolicy", + "confirmationStatement", + "purpose", + "scope", + "issuedAt", + "validFrom", + "statusQuery", + ]; + for (const field of required) { + if (value[field] === undefined || value[field] === "") { + throw new TypeError(`${field} is required`); + } + } + if (value.credentialVersion !== "reference-v1") { + throw new TypeError("credentialVersion must be reference-v1"); + } + const stringFields = required.filter( + (field) => !["associatedCreditValue", "mappingPolicy", "scope", "statusQuery"].includes(field), + ); + for (const field of stringFields) { + if (typeof value[field] !== "string" || value[field].trim() === "") { + throw new TypeError(`${field} must be a non-empty string`); + } + } + if ( + !Array.isArray(value.scope) + || value.scope.length === 0 + || value.scope.some((entry) => typeof entry !== "string" || entry.trim() === "") + || new Set(value.scope).size !== value.scope.length + ) { + throw new TypeError("scope must be a non-empty set of strings"); + } + const statusQuery = value.statusQuery as Record; + if ( + statusQuery === null + || typeof statusQuery !== "object" + || typeof statusQuery.uri !== "string" + || statusQuery.uri.trim() === "" + ) { + throw new TypeError("statusQuery.uri is required"); + } + for (const field of ["issuedAt", "validFrom", "expiresAt"] as const) { + if (value[field] !== undefined && Number.isNaN(Date.parse(String(value[field])))) { + throw new TypeError(`${field} must be an RFC 3339 timestamp`); + } + } + if ( + value.expiresAt !== undefined + && Date.parse(String(value.expiresAt)) <= Date.parse(String(value.validFrom)) + ) { + throw new TypeError("expiresAt must be after validFrom"); + } + if (value.creditSource !== "ASSOCIATED_CREDIT") { + throw new TypeError("creditSource must be ASSOCIATED_CREDIT"); + } + if (value.confirmationMethod === "DIRECT_SIGNATURE") { + if ( + typeof value.associationApplicationRequestedAt !== "string" + || Number.isNaN(Date.parse(value.associationApplicationRequestedAt)) + ) { + throw new TypeError( + "associationApplicationRequestedAt is required for DIRECT_SIGNATURE", + ); + } + const antiReplay = value.associationApplicationAntiReplay; + if (antiReplay === null || Array.isArray(antiReplay) || typeof antiReplay !== "object") { + throw new TypeError( + "associationApplicationAntiReplay is required for DIRECT_SIGNATURE", + ); + } + const record = antiReplay as Record; + const nonce = record.nonce; + const idempotencyKey = record.idempotencyKey; + if ( + (typeof nonce !== "string" || nonce.length < 16) + && (typeof idempotencyKey !== "string" || idempotencyKey.length < 8) + ) { + throw new TypeError( + "associationApplicationAntiReplay requires nonce or idempotencyKey", + ); + } + } + + const trace = mappingTraceFromCredential(value as unknown as AssociationCredential); + if (trace !== undefined) { + const result = validateMappingTrace(trace); + if (!result.valid) throw new TypeError(result.errors.join("; ")); + } +} + +export function subjectConfirmationPayload( + credential: AssociationCredential | Record, +): Record { + const payload = withoutKeys(credential as Record, [ + "subjectPublicKey", + "subjectSignature", + "subjectSignatureAlgorithm", + "issuerSignature", + "issuerSignatureAlgorithm", + "previousCredentialRef", + ]); + payload.confirmationMethod = "DIRECT_SIGNATURE"; + return payload; +} + +export function issuerCredentialPayload( + credential: AssociationCredential | Record, +): Record { + return withoutKeys(credential as Record, [ + "issuerSignature", + "issuerSignatureAlgorithm", + "statusQuery", + "previousCredentialRef", + ]); +} + +export function createDirectConfirmation(input: { + credential: AssociationCredential | Record; + subjectPrivateKey: KeyMaterial; + subjectKeyId?: string; +}): SignatureProof { + return signCanonical( + subjectConfirmationPayload(input.credential), + input.subjectPrivateKey, + input.subjectKeyId, + ); +} + +export function issueAttestedCredential( + input: IssueAttestedCredentialInput, +): AssociationCredential { + const base = withoutKeys(input.credential as Record, [ + "subjectPublicKey", + "subjectSignature", + "subjectSignatureAlgorithm", + "issuerSignature", + "issuerSignatureAlgorithm", + ]); + const credential = { + ...base, + confirmationMethod: "ATTESTED_CONFIRMATION", + } as unknown as AssociationCredential; + assertCredentialBase(credential as unknown as Record); + const proof = signCanonical( + issuerCredentialPayload(credential), + input.issuerPrivateKey, + input.issuerKeyId, + ); + return { + ...credential, + issuerSignature: proof.signatureValue, + issuerSignatureAlgorithm: proof.signatureAlgorithm, + }; +} + +export function issueDirectCredential( + input: IssueDirectCredentialInput, +): AssociationCredential { + const suppliedPublicKey = input.subjectPublicKey + ?? (input.credential as AssociationCredential).subjectPublicKey; + if (suppliedPublicKey === undefined) { + throw new TypeError("subjectPublicKey is required for DIRECT_SIGNATURE"); + } + + const base = withoutKeys(input.credential as Record, [ + "subjectSignature", + "subjectSignatureAlgorithm", + "issuerSignature", + "issuerSignatureAlgorithm", + ]); + const unsigned = { + ...base, + confirmationMethod: "DIRECT_SIGNATURE", + subjectPublicKey: structuredClone(suppliedPublicKey), + } as unknown as AssociationCredential; + assertCredentialBase(unsigned as unknown as Record); + + const subjectProof = createDirectConfirmation({ + credential: unsigned, + subjectPrivateKey: input.subjectPrivateKey, + subjectKeyId: input.subjectKeyId ?? suppliedPublicKey.keyId, + }); + const subjectSigned: AssociationCredential = { + ...unsigned, + subjectSignature: subjectProof.signatureValue, + subjectSignatureAlgorithm: subjectProof.signatureAlgorithm, + issuerSignature: "", + issuerSignatureAlgorithm: "", + }; + const issuerProof = signCanonical( + issuerCredentialPayload(subjectSigned), + input.issuerPrivateKey, + input.issuerKeyId, + ); + return { + ...subjectSigned, + issuerSignature: issuerProof.signatureValue, + issuerSignatureAlgorithm: issuerProof.signatureAlgorithm, + }; +} + +/** + * Issuer-side DIRECT_SIGNATURE flow. The subject signs locally and submits + * only its public key and inner signature. The issuer verifies that proof + * before adding the outer signature. + */ +export function issueDirectCredentialFromConfirmation( + input: IssueDirectCredentialFromConfirmationInput, +): AssociationCredential { + const base = withoutKeys(input.credential as Record, [ + "subjectPublicKey", + "subjectSignature", + "subjectSignatureAlgorithm", + "issuerSignature", + "issuerSignatureAlgorithm", + ]); + const subjectSigned = { + ...base, + confirmationMethod: "DIRECT_SIGNATURE", + subjectPublicKey: structuredClone(input.subjectPublicKey), + subjectSignature: input.subjectSignature, + subjectSignatureAlgorithm: input.subjectSignatureAlgorithm, + issuerSignature: "", + issuerSignatureAlgorithm: "", + } as unknown as AssociationCredential; + assertCredentialBase(subjectSigned as unknown as Record); + const subjectValid = verifyCanonicalSignature( + subjectConfirmationPayload(subjectSigned), + { + signatureAlgorithm: input.subjectSignatureAlgorithm, + signatureValue: input.subjectSignature, + keyId: input.subjectPublicKey.keyId, + }, + input.subjectPublicKey, + ); + if (!subjectValid) throw new Error("ASSOCIATION_PROOF_INVALID"); + + const issuerProof = signCanonical( + issuerCredentialPayload(subjectSigned), + input.issuerPrivateKey, + input.issuerKeyId, + ); + return { + ...subjectSigned, + issuerSignature: issuerProof.signatureValue, + issuerSignatureAlgorithm: issuerProof.signatureAlgorithm, + }; +} + +export interface VerifyCredentialProofInput { + credential: AssociationCredential; + issuerPublicKey: PublicKeyReference | KeyMaterial; + subjectPublicKey?: PublicKeyReference | KeyMaterial; +} + +export function verifyCredentialProof(input: VerifyCredentialProofInput): boolean { + const { credential } = input; + if (credential.issuerSignatureAlgorithm !== "Ed25519") return false; + const issuerValid = verifyCanonicalSignature( + issuerCredentialPayload(credential), + { + signatureAlgorithm: credential.issuerSignatureAlgorithm, + signatureValue: credential.issuerSignature, + }, + input.issuerPublicKey, + ); + if (!issuerValid) return false; + + if (credential.confirmationMethod === "ATTESTED_CONFIRMATION") { + return credential.subjectPublicKey === undefined + && credential.subjectSignature === undefined + && credential.subjectSignatureAlgorithm === undefined; + } + if ( + credential.confirmationMethod !== "DIRECT_SIGNATURE" + || credential.subjectPublicKey === undefined + || credential.subjectSignature === undefined + || credential.subjectSignatureAlgorithm !== "Ed25519" + ) { + return false; + } + return verifyCanonicalSignature( + subjectConfirmationPayload(credential), + { + signatureAlgorithm: credential.subjectSignatureAlgorithm, + signatureValue: credential.subjectSignature, + }, + input.subjectPublicKey ?? credential.subjectPublicKey, + ); +} diff --git a/code/samples/tsd-crd-reference/src/core/authorization.ts b/code/samples/tsd-crd-reference/src/core/authorization.ts new file mode 100644 index 0000000..a3b4ce9 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/authorization.ts @@ -0,0 +1,304 @@ +import { + signCanonical, + type KeyMaterial, + verifyCanonicalSignature, +} from "./crypto.ts"; +import type { + AuthorizationEvaluation, + AuthorizationEvaluationRequest, + AuthorizationRuntimeState, + AuthorizationUsage, + CreditQueryAuthorization, + PublicKeyReference, + ResultUseRestrictions, + SignatureProof, +} from "./types.ts"; + +export interface CreateAuthorizationInput { + authorizationId: string; + authorizationVersion?: "reference-v1"; + mode: "PER_REQUEST" | "PLATFORM_DELEGATED"; + subjectId: string; + relyingPartyIds?: string[]; + relyingPartyId?: string; + platformDelegateId?: string; + platformAgentId?: string; + agentIds: string[]; + verificationLevel?: "ASSOCIATED_CREDIT"; + purpose: string; + allowedDataItems?: string[]; + dataItems?: string[]; + validFrom: string; + expiresAt?: string; + validUntil?: string; + frequencyLimit?: { + maxRequestsPerWindow: number; + windowDurationSeconds: number; + }; + maxRequestsPerWindow?: number; + windowDurationSeconds?: number; + resultUseRestrictions?: ResultUseRestrictions; + boundRequestId?: string; + requestId?: string; + subjectPrivateKey?: KeyMaterial; + subjectKeyId?: string; + authorizationProof?: SignatureProof; +} + +function assertNonEmpty(values: string[], field: string): void { + if (values.length === 0 || values.some((value) => value.trim() === "")) { + throw new TypeError(`${field} must contain non-empty values`); + } + if (new Set(values).size !== values.length) { + throw new TypeError(`${field} must not contain duplicates`); + } +} + +function authorizationPayload( + authorization: Record, +): Record { + const copy = structuredClone(authorization) as unknown as Record; + delete copy.authorizationProof; + delete copy.status; + return copy; +} + +export function createAuthorization( + input: CreateAuthorizationInput, +): CreditQueryAuthorization { + const relyingPartyIds = input.relyingPartyIds + ?? (input.relyingPartyId === undefined ? [] : [input.relyingPartyId]); + const allowedDataItems = input.allowedDataItems ?? input.dataItems ?? []; + const expiresAt = input.expiresAt ?? input.validUntil; + if (expiresAt === undefined) throw new TypeError("expiresAt is required"); + const platformDelegateId = input.platformDelegateId ?? input.platformAgentId; + const boundRequestId = input.boundRequestId ?? input.requestId; + const frequencyLimit = input.frequencyLimit + ?? ( + input.maxRequestsPerWindow !== undefined + && input.windowDurationSeconds !== undefined + ? { + maxRequestsPerWindow: input.maxRequestsPerWindow, + windowDurationSeconds: input.windowDurationSeconds, + } + : undefined + ); + + for (const [field, value] of [ + ["authorizationId", input.authorizationId], + ["subjectId", input.subjectId], + ["purpose", input.purpose], + ] as const) { + if (value.trim() === "") throw new TypeError(`${field} is required`); + } + if ( + Number.isNaN(Date.parse(input.validFrom)) + || Number.isNaN(Date.parse(expiresAt)) + ) { + throw new TypeError("validFrom and expiresAt must be RFC 3339 timestamps"); + } + if ( + input.verificationLevel !== undefined + && input.verificationLevel !== "ASSOCIATED_CREDIT" + ) { + throw new TypeError("verificationLevel must be ASSOCIATED_CREDIT"); + } + if ( + input.authorizationVersion !== undefined + && input.authorizationVersion !== "reference-v1" + ) { + throw new TypeError("authorizationVersion must be reference-v1"); + } + + assertNonEmpty(relyingPartyIds, "relyingPartyIds"); + assertNonEmpty(input.agentIds, "agentIds"); + assertNonEmpty(allowedDataItems, "allowedDataItems"); + if (Date.parse(input.validFrom) >= Date.parse(expiresAt)) { + throw new TypeError("expiresAt must be after validFrom"); + } + + if (input.mode === "PER_REQUEST") { + if (boundRequestId === undefined) { + throw new TypeError("boundRequestId is required for PER_REQUEST"); + } + if (boundRequestId.trim() === "") { + throw new TypeError("boundRequestId must be non-empty"); + } + if (platformDelegateId !== undefined || frequencyLimit !== undefined) { + throw new TypeError( + "PER_REQUEST forbids platformDelegateId and frequencyLimit", + ); + } + } else { + if (platformDelegateId === undefined || frequencyLimit === undefined) { + throw new TypeError( + "PLATFORM_DELEGATED requires platformDelegateId and frequencyLimit", + ); + } + if (platformDelegateId.trim() === "") { + throw new TypeError("platformDelegateId must be non-empty"); + } + if (boundRequestId !== undefined) { + throw new TypeError("PLATFORM_DELEGATED forbids boundRequestId"); + } + if ( + !Number.isInteger(frequencyLimit.maxRequestsPerWindow) + || frequencyLimit.maxRequestsPerWindow <= 0 + || !Number.isInteger(frequencyLimit.windowDurationSeconds) + || frequencyLimit.windowDurationSeconds <= 0 + ) { + throw new TypeError("frequencyLimit values must be positive integers"); + } + } + + const restrictions = input.resultUseRestrictions ?? { + mayStore: false, + mayTransfer: false, + }; + if ( + restrictions.mayStore + && (!Number.isInteger(restrictions.retentionSeconds) + || (restrictions.retentionSeconds ?? 0) <= 0) + ) { + throw new TypeError("retentionSeconds is required when mayStore is true"); + } + + const unsigned = { + authorizationId: input.authorizationId, + authorizationVersion: input.authorizationVersion ?? "reference-v1", + mode: input.mode, + subjectId: input.subjectId, + relyingPartyIds: [...relyingPartyIds], + ...(platformDelegateId === undefined ? {} : { platformDelegateId }), + agentIds: [...input.agentIds], + verificationLevel: "ASSOCIATED_CREDIT", + purpose: input.purpose, + allowedDataItems: [...allowedDataItems], + validFrom: input.validFrom, + expiresAt, + ...(frequencyLimit === undefined ? {} : { frequencyLimit }), + resultUseRestrictions: restrictions, + status: "ACTIVE", + ...(boundRequestId === undefined ? {} : { boundRequestId }), + } satisfies Omit; + + if (input.authorizationProof === undefined && input.subjectPrivateKey === undefined) { + throw new TypeError("authorizationProof or subjectPrivateKey is required"); + } + if ( + input.authorizationProof !== undefined + && ( + input.authorizationProof.signatureAlgorithm !== "Ed25519" + || input.authorizationProof.signatureValue.trim() === "" + ) + ) { + throw new TypeError("authorizationProof must use Ed25519 and be non-empty"); + } + const proof = input.authorizationProof + ?? signCanonical( + authorizationPayload(unsigned as unknown as Record), + input.subjectPrivateKey!, + input.subjectKeyId, + ); + return { ...unsigned, authorizationProof: proof }; +} + +export function evaluateAuthorization( + authorization: CreditQueryAuthorization, + request: AuthorizationEvaluationRequest & { dataItems?: string[] }, + now = new Date().toISOString(), + usage: AuthorizationUsage[] = [], +): AuthorizationEvaluation & { authorization: CreditQueryAuthorization } { + const requestedDataItems = request.requestedDataItems ?? request.dataItems ?? []; + const currentState: AuthorizationRuntimeState = { + useCount: usage.length, + usageTimestamps: usage.map((entry) => entry.usedAt), + }; + const deny = (reasonCode: AuthorizationEvaluation["reasonCode"]) => ({ + allowed: false, + reasonCode, + authorizedDataItems: [], + runtimeState: currentState, + authorization: structuredClone(authorization), + }); + + if (authorization.status === "REVOKED") return deny("AUTHORIZATION_REVOKED"); + if ( + authorization.status === "EXPIRED" + || Date.parse(now) < Date.parse(authorization.validFrom) + || Date.parse(now) >= Date.parse(authorization.expiresAt) + ) { + return deny("AUTHORIZATION_EXPIRED"); + } + if ( + request.verificationLevel !== "ASSOCIATED_CREDIT" + || !authorization.relyingPartyIds.includes(request.relyingPartyId) + || !authorization.agentIds.includes(request.agentId) + || authorization.purpose !== request.purpose + || requestedDataItems.some( + (item) => !authorization.allowedDataItems.includes(item), + ) + || ( + authorization.mode === "PER_REQUEST" + && authorization.boundRequestId !== request.requestId + ) + || ( + authorization.mode === "PLATFORM_DELEGATED" + && authorization.platformDelegateId !== request.platformDelegateId + ) + ) { + return deny("AUTHORIZATION_SCOPE_MISMATCH"); + } + + if (authorization.mode === "PER_REQUEST" && currentState.useCount > 0) { + return deny("AUTHORIZATION_SCOPE_MISMATCH"); + } + if (authorization.frequencyLimit !== undefined) { + const windowStart = Date.parse(now) + - authorization.frequencyLimit.windowDurationSeconds * 1_000; + const recentUses = usage.filter( + (entry) => Date.parse(entry.usedAt) > windowStart && Date.parse(entry.usedAt) <= Date.parse(now), + ); + if (recentUses.length >= authorization.frequencyLimit.maxRequestsPerWindow) { + return deny("AUTHORIZATION_SCOPE_MISMATCH"); + } + } + + return { + allowed: true, + reasonCode: "VERIFIED", + authorizedDataItems: [...requestedDataItems], + runtimeState: { + useCount: currentState.useCount + 1, + usageTimestamps: [...currentState.usageTimestamps, now], + }, + authorization: structuredClone(authorization), + }; +} + +export function revokeAuthorization( + authorization: CreditQueryAuthorization, + _revokedAt = new Date().toISOString(), +): CreditQueryAuthorization { + return { + ...structuredClone(authorization), + status: "REVOKED", + }; +} + +export function authorizationSigningPayload( + authorization: CreditQueryAuthorization, +): Record { + return authorizationPayload(authorization as unknown as Record); +} + +export function verifyAuthorizationProof( + authorization: CreditQueryAuthorization, + subjectPublicKey: PublicKeyReference | KeyMaterial, +): boolean { + return verifyCanonicalSignature( + authorizationSigningPayload(authorization), + authorization.authorizationProof, + subjectPublicKey, + ); +} diff --git a/code/samples/tsd-crd-reference/src/core/canonical.ts b/code/samples/tsd-crd-reference/src/core/canonical.ts new file mode 100644 index 0000000..aedc80f --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/canonical.ts @@ -0,0 +1,84 @@ +function assertWellFormedUnicode(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new TypeError("Canonical JSON rejects unpaired high surrogates"); + } + index += 1; + continue; + } + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw new TypeError("Canonical JSON rejects unpaired low surrogates"); + } + } +} + +function serialize(value: unknown, stack: Set): string { + if (value === null) return "null"; + + if (typeof value === "string") { + assertWellFormedUnicode(value); + return JSON.stringify(value); + } + + if (typeof value === "boolean") return value ? "true" : "false"; + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Canonical JSON only supports finite numbers"); + } + if (Object.is(value, -0)) { + throw new TypeError("Canonical JSON rejects negative zero"); + } + return JSON.stringify(value); + } + + if (typeof value !== "object") { + throw new TypeError(`Canonical JSON cannot serialize ${typeof value}`); + } + + if (stack.has(value)) { + throw new TypeError("Canonical JSON cannot serialize cyclic values"); + } + stack.add(value); + + try { + if (Array.isArray(value)) { + const items: string[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new TypeError("Canonical JSON rejects sparse arrays"); + } + items.push(serialize(value[index], stack)); + } + return `[${items.join(",")}]`; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Canonical JSON only supports plain objects"); + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + const members = keys.map((key) => { + assertWellFormedUnicode(key); + return `${JSON.stringify(key)}:${serialize(record[key], stack)}`; + }); + return `{${members.join(",")}}`; + } finally { + stack.delete(value); + } +} + +/** + * Produces deterministic JSON using RFC 8785-compatible ordering and number + * rendering. Values outside the interoperable JSON data model are rejected. + */ +export function canonicalize(value: unknown): string { + return serialize(value, new Set()); +} + +export const canonicalJson = canonicalize; diff --git a/code/samples/tsd-crd-reference/src/core/crypto.ts b/code/samples/tsd-crd-reference/src/core/crypto.ts new file mode 100644 index 0000000..05295f0 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/crypto.ts @@ -0,0 +1,84 @@ +import { + generateKeyPairSync, + sign as nodeSign, + verify as nodeVerify, +} from "node:crypto"; + +import { canonicalize } from "./canonical.ts"; +import type { + Ed25519KeyPair, + PublicKeyReference, + SignatureProof, +} from "./types.ts"; + +export type KeyMaterial = string | Uint8Array; + +function randomKeyId(): string { + return `key-${crypto.randomUUID()}`; +} + +function keyValue(key: PublicKeyReference | KeyMaterial): KeyMaterial { + if (typeof key === "object" && !(key instanceof Uint8Array)) { + return key.value; + } + return key; +} + +export function generateEd25519KeyPair(keyId = randomKeyId()): Ed25519KeyPair { + const pair = generateKeyPairSync("ed25519"); + const publicKey = pair.publicKey.export({ type: "spki", format: "pem" }).toString(); + const privateKey = pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + + return { + algorithm: "Ed25519", + keyId, + publicKey: { + keyId, + format: "pem-spki", + value: publicKey, + }, + privateKey, + }; +} + +export function signCanonical( + value: unknown, + privateKey: KeyMaterial, + keyId?: string, +): SignatureProof { + const payload = Buffer.from(canonicalize(value), "utf8"); + const signatureValue = nodeSign(null, payload, privateKey).toString("base64url"); + return { + signatureAlgorithm: "Ed25519", + signatureValue, + ...(keyId === undefined ? {} : { keyId }), + }; +} + +export function verifyCanonicalSignature( + value: unknown, + proof: SignatureProof | string, + publicKey: PublicKeyReference | KeyMaterial, +): boolean { + const signatureAlgorithm = + typeof proof === "string" ? "Ed25519" : proof.signatureAlgorithm; + if (signatureAlgorithm !== "Ed25519") return false; + + try { + const payload = Buffer.from(canonicalize(value), "utf8"); + const signatureValue = + typeof proof === "string" ? proof : proof.signatureValue; + return nodeVerify( + null, + payload, + keyValue(publicKey), + Buffer.from(signatureValue, "base64url"), + ); + } catch { + return false; + } +} + +export const generateKeyPair = generateEd25519KeyPair; +export const sign = signCanonical; +export const verify = verifyCanonicalSignature; diff --git a/code/samples/tsd-crd-reference/src/core/index.ts b/code/samples/tsd-crd-reference/src/core/index.ts new file mode 100644 index 0000000..15b278e --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/index.ts @@ -0,0 +1,8 @@ +export * from "./types.ts"; +export * from "./canonical.ts"; +export * from "./crypto.ts"; +export * from "./mapping.ts"; +export * from "./lifecycle.ts"; +export * from "./association.ts"; +export * from "./authorization.ts"; +export * from "./verification.ts"; diff --git a/code/samples/tsd-crd-reference/src/core/lifecycle.ts b/code/samples/tsd-crd-reference/src/core/lifecycle.ts new file mode 100644 index 0000000..70140a1 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/lifecycle.ts @@ -0,0 +1,56 @@ +import type { + CredentialStatus, + CredentialStatusRecord, +} from "./types.ts"; + +const ALLOWED_TRANSITIONS: Readonly> = { + PENDING: ["ACTIVE", "REVOKED", "EXPIRED"], + ACTIVE: ["SUSPENDED", "REVOKED", "EXPIRED"], + SUSPENDED: ["ACTIVE", "REVOKED", "EXPIRED"], + REVOKED: [], + EXPIRED: [], +}; + +export function canTransitionStatus( + current: CredentialStatus, + target: CredentialStatus, +): boolean { + return ALLOWED_TRANSITIONS[current].includes(target); +} + +export function transitionStatus( + current: CredentialStatus, + target: CredentialStatus, + changedAt: string, + reasonCode?: string, + credentialId = "", +): CredentialStatusRecord { + if (!canTransitionStatus(current, target)) { + throw new Error(`Invalid credential status transition: ${current} -> ${target}`); + } + if (Number.isNaN(Date.parse(changedAt))) { + throw new TypeError("changedAt must be an RFC 3339 timestamp"); + } + return { + credentialId, + previousStatus: current, + status: target, + changedAt, + ...(reasonCode === undefined ? {} : { reasonCode }), + }; +} + +export function effectiveCredentialStatus( + record: CredentialStatusRecord, + expiresAt: string | undefined, + now: string, +): CredentialStatus { + if ( + record.status !== "REVOKED" + && expiresAt !== undefined + && Date.parse(now) >= Date.parse(expiresAt) + ) { + return "EXPIRED"; + } + return record.status; +} diff --git a/code/samples/tsd-crd-reference/src/core/mapping.ts b/code/samples/tsd-crd-reference/src/core/mapping.ts new file mode 100644 index 0000000..4fa152e --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/mapping.ts @@ -0,0 +1,83 @@ +import { canonicalize } from "./canonical.ts"; +import type { JsonValue, MappingPolicyReference, MappingTrace } from "./types.ts"; + +export interface CreateMappingTraceInput { + subjectCreditAssertionRef: string; + associatedCreditValue: JsonValue; + mappingPolicy: MappingPolicyReference; +} + +function nonBlank(value: string): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +export function createMappingTrace(input: CreateMappingTraceInput): MappingTrace { + const trace: MappingTrace = { + subjectCreditAssertionRef: input.subjectCreditAssertionRef, + associatedCreditValue: structuredClone(input.associatedCreditValue), + mappingPolicy: structuredClone(input.mappingPolicy), + creditSource: "ASSOCIATED_CREDIT", + }; + const result = validateMappingTrace(trace); + if (!result.valid) throw new TypeError(result.errors.join("; ")); + return trace; +} + +export interface MappingValidationResult { + valid: boolean; + errors: string[]; +} + +export function validateMappingTrace(trace: MappingTrace): MappingValidationResult { + const errors: string[] = []; + if (!nonBlank(trace.subjectCreditAssertionRef)) { + errors.push("subjectCreditAssertionRef is required"); + } + if (!nonBlank(trace.mappingPolicy?.id)) errors.push("mappingPolicy.id is required"); + if (!nonBlank(trace.mappingPolicy?.version)) { + errors.push("mappingPolicy.version is required"); + } + if (trace.creditSource !== "ASSOCIATED_CREDIT") { + errors.push("creditSource must be ASSOCIATED_CREDIT"); + } + if (trace.associatedCreditValue === null) { + errors.push("associatedCreditValue must not be null"); + } else if ( + typeof trace.associatedCreditValue === "string" + && trace.associatedCreditValue.length === 0 + ) { + errors.push("associatedCreditValue must not be empty"); + } else if ( + Array.isArray(trace.associatedCreditValue) + && trace.associatedCreditValue.length === 0 + ) { + errors.push("associatedCreditValue must not be an empty array"); + } else if ( + typeof trace.associatedCreditValue === "object" + && !Array.isArray(trace.associatedCreditValue) + && Object.keys(trace.associatedCreditValue).length === 0 + ) { + errors.push("associatedCreditValue must not be an empty object"); + } + try { + canonicalize(trace.associatedCreditValue); + } catch (error) { + errors.push(error instanceof Error ? error.message : "associatedCreditValue is invalid"); + } + return { valid: errors.length === 0, errors }; +} + +export function mappingTraceFromCredential(input: { + subjectCreditAssertionRef?: string; + associatedCreditValue: JsonValue; + mappingPolicy: MappingPolicyReference; + creditSource: "ASSOCIATED_CREDIT"; +}): MappingTrace | undefined { + if (input.subjectCreditAssertionRef === undefined) return undefined; + return { + subjectCreditAssertionRef: input.subjectCreditAssertionRef, + associatedCreditValue: structuredClone(input.associatedCreditValue), + mappingPolicy: structuredClone(input.mappingPolicy), + creditSource: input.creditSource, + }; +} diff --git a/code/samples/tsd-crd-reference/src/core/types.ts b/code/samples/tsd-crd-reference/src/core/types.ts new file mode 100644 index 0000000..87117c7 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/types.ts @@ -0,0 +1,272 @@ +export type JsonPrimitive = null | boolean | number | string; + +export type JsonValue = + | JsonPrimitive + | JsonValue[] + | { [key: string]: JsonValue }; + +export const CONFIRMATION_METHODS = [ + "DIRECT_SIGNATURE", + "ATTESTED_CONFIRMATION", +] as const; +export type ConfirmationMethod = (typeof CONFIRMATION_METHODS)[number]; + +export const CREDENTIAL_STATUSES = [ + "PENDING", + "ACTIVE", + "SUSPENDED", + "REVOKED", + "EXPIRED", +] as const; +export type CredentialStatus = (typeof CREDENTIAL_STATUSES)[number]; + +export const VERIFICATION_LEVELS = [ + "CREDENTIAL", + "ASSOCIATED_CREDIT", +] as const; +export type VerificationLevel = (typeof VERIFICATION_LEVELS)[number]; + +export const VERIFICATION_RESULTS = [ + "PASS", + "FAIL", + "INCONCLUSIVE", + "REVIEW_REQUIRED", +] as const; +export type VerificationResult = (typeof VERIFICATION_RESULTS)[number]; + +export const REASON_CODES = [ + "VERIFIED", + "INVALID_REQUEST", + "REQUEST_PROOF_INVALID", + "REQUEST_EXPIRED", + "REPLAY_DETECTED", + "AUTHORIZATION_REQUIRED", + "AUTHORIZATION_EXPIRED", + "AUTHORIZATION_REVOKED", + "AUTHORIZATION_SCOPE_MISMATCH", + "AGENT_NOT_REGISTERED", + "ASSOCIATION_CREDENTIAL_NOT_FOUND", + "ASSOCIATION_CREDENTIAL_NOT_ACTIVE", + "ASSOCIATION_PROOF_INVALID", + "CREDIT_ASSERTION_UNAVAILABLE", + "CREDIT_ASSERTION_INVALID", + "MAPPING_POLICY_UNSUPPORTED", + "INCONCLUSIVE", + "REVIEW_REQUIRED", +] as const; +export type ReasonCode = (typeof REASON_CODES)[number]; + +export interface PublicKeyReference { + keyId: string; + format: "pem-spki"; + value: string; +} + +export interface SignatureProof { + signatureAlgorithm: "Ed25519" | string; + signatureValue: string; + keyId?: string; +} + +export interface Ed25519KeyPair { + algorithm: "Ed25519"; + keyId: string; + publicKey: PublicKeyReference; + privateKey: string; +} + +export interface MappingPolicyReference { + id: string; + version: string; +} + +export interface MappingTrace { + subjectCreditAssertionRef: string; + associatedCreditValue: JsonValue; + mappingPolicy: MappingPolicyReference; + creditSource: "ASSOCIATED_CREDIT"; +} + +export interface StatusQueryReference { + uri: string; + method?: "GET" | "POST"; +} + +export interface AssociationCredential { + credentialId: string; + credentialVersion: string; + associationApplicationId: string; + agentId: string; + subjectId: string; + associationRole?: string; + issuerId: string; + relationshipEvidenceRef: string; + confirmationMethod: ConfirmationMethod; + subjectCreditAssertionRef: string; + associatedCreditValue: JsonValue; + creditSource: "ASSOCIATED_CREDIT"; + mappingPolicy: MappingPolicyReference; + confirmationStatement: string; + purpose: string; + scope: string[]; + associationApplicationRequestedAt?: string; + associationApplicationAntiReplay?: AntiReplay; + subjectPublicKey?: PublicKeyReference; + subjectSignature?: string; + subjectSignatureAlgorithm?: string; + issuedAt: string; + validFrom: string; + expiresAt?: string; + statusQuery: StatusQueryReference; + previousCredentialRef?: string; + issuerSignature: string; + issuerSignatureAlgorithm: string; +} + +export type UnsignedAssociationCredential = Omit< + AssociationCredential, + | "subjectSignature" + | "subjectSignatureAlgorithm" + | "issuerSignature" + | "issuerSignatureAlgorithm" +> & { + subjectSignature?: never; + subjectSignatureAlgorithm?: never; + issuerSignature?: never; + issuerSignatureAlgorithm?: never; +}; + +export interface CredentialStatusRecord { + credentialId: string; + status: CredentialStatus; + changedAt: string; + reasonCode?: string; + previousStatus?: CredentialStatus; +} + +export const AUTHORIZATION_MODES = ["PER_REQUEST", "PLATFORM_DELEGATED"] as const; +export type AuthorizationMode = (typeof AUTHORIZATION_MODES)[number]; + +export const AUTHORIZATION_STATUSES = ["ACTIVE", "REVOKED", "EXPIRED"] as const; +export type AuthorizationStatus = (typeof AUTHORIZATION_STATUSES)[number]; + +export interface FrequencyLimit { + maxRequestsPerWindow: number; + windowDurationSeconds: number; +} + +export interface ResultUseRestrictions { + mayStore: boolean; + retentionSeconds?: number; + mayTransfer: boolean; +} + +export type BusinessContext = Record; + +export interface CreditQueryAuthorization { + authorizationId: string; + authorizationVersion: "reference-v1"; + mode: AuthorizationMode; + subjectId: string; + relyingPartyIds: string[]; + platformDelegateId?: string; + agentIds: string[]; + verificationLevel: "ASSOCIATED_CREDIT"; + purpose: string; + allowedDataItems: string[]; + validFrom: string; + expiresAt: string; + frequencyLimit?: FrequencyLimit; + resultUseRestrictions: ResultUseRestrictions; + status: AuthorizationStatus; + boundRequestId?: string; + authorizationProof: SignatureProof; +} + +export type QueryAuthorization = CreditQueryAuthorization; + +export type UnsignedCreditQueryAuthorization = Omit< + CreditQueryAuthorization, + "authorizationProof" | "status" +> & { + authorizationProof?: never; + status?: never; +}; + +export type UnsignedQueryAuthorization = UnsignedCreditQueryAuthorization; + +export interface AuthorizationEvaluationRequest { + requestId: string; + relyingPartyId: string; + agentId: string; + verificationLevel: VerificationLevel; + purpose: string; + requestedDataItems: string[]; + platformDelegateId?: string; +} + +export interface AntiReplay { + nonce?: string; + idempotencyKey?: string; +} + +export interface AuthorizationUsage { + usedAt: string; +} + +export interface AuthorizationRuntimeState { + useCount: number; + usageTimestamps: string[]; +} + +export interface AuthorizationEvaluation { + allowed: boolean; + reasonCode: ReasonCode; + authorizedDataItems: string[]; + runtimeState: AuthorizationRuntimeState; +} + +export interface CredentialReferenceInput { + credentialId: string; + uri?: string; +} + +export type CredentialInput = AssociationCredential | CredentialReferenceInput; + +export interface VerificationRequest { + requestId: string; + messageVersion: string; + relyingPartyId: string; + agentId: string; + verificationLevel: VerificationLevel; + subjectCreditAssertionRef?: string; + authorizationId?: string; + platformDelegateId?: string; + purpose: string; + businessContext: BusinessContext; + requestedDataItems: string[]; + credential: CredentialInput; + requestedAt: string; + antiReplay: AntiReplay; + requestProof: SignatureProof; +} + +export interface VerificationResponse { + requestId: string; + verificationRecordId: string; + agentId: string; + completedLevel: VerificationLevel; + result: VerificationResult; + reasonCode: ReasonCode; + credentialStatus: CredentialStatus; + creditSource?: "ASSOCIATED_CREDIT"; + associatedCreditValue?: JsonValue; + subjectCreditAssertionValid?: boolean; + mappingPolicy?: MappingPolicyReference; + scope: string[]; + generatedAt: string; + expiresAt: string; + statusQuery?: StatusQueryReference; + purposeLimited: true; + responseProof: SignatureProof; +} diff --git a/code/samples/tsd-crd-reference/src/core/verification.ts b/code/samples/tsd-crd-reference/src/core/verification.ts new file mode 100644 index 0000000..5043ea0 --- /dev/null +++ b/code/samples/tsd-crd-reference/src/core/verification.ts @@ -0,0 +1,369 @@ +import { randomUUID } from "node:crypto"; + +import { verifyCredentialProof } from "./association.ts"; +import { evaluateAuthorization } from "./authorization.ts"; +import { effectiveCredentialStatus } from "./lifecycle.ts"; +import { mappingTraceFromCredential, validateMappingTrace } from "./mapping.ts"; +import type { + AssociationCredential, + AuthorizationEvaluationRequest, + CredentialStatus, + CredentialStatusRecord, + CreditQueryAuthorization, + PublicKeyReference, + ReasonCode, + SignatureProof, + VerificationRequest, + VerificationResponse, + VerificationResult, +} from "./types.ts"; +import { + signCanonical, + type KeyMaterial, + verifyCanonicalSignature, +} from "./crypto.ts"; + +export interface VerifyAssociationCredentialInput { + credential: AssociationCredential; + issuerPublicKey: PublicKeyReference | KeyMaterial; + subjectPublicKey?: PublicKeyReference | KeyMaterial; + status: CredentialStatus | CredentialStatusRecord; + agentId: string; + purpose: string; + requestedDataItems?: string[]; + now?: string; +} + +export interface CoreVerificationResult { + result: VerificationResult; + reasonCode: ReasonCode; + completedVerificationLevel: "CREDENTIAL" | "ASSOCIATED_CREDIT"; + credentialStatus: CredentialStatus; + scope: string[]; + creditSource?: "ASSOCIATED_CREDIT"; + associatedCreditValue?: AssociationCredential["associatedCreditValue"]; + subjectCreditAssertionValid?: boolean; + mappingPolicy?: AssociationCredential["mappingPolicy"]; +} + +function result( + input: VerifyAssociationCredentialInput, + verificationResult: VerificationResult, + reasonCode: ReasonCode, + credentialStatus: CredentialStatus, +): CoreVerificationResult { + return { + result: verificationResult, + reasonCode, + completedVerificationLevel: "CREDENTIAL", + credentialStatus, + scope: [...input.credential.scope], + }; +} + +function statusRecord( + credential: AssociationCredential, + status: CredentialStatus | CredentialStatusRecord, + now: string, +): CredentialStatus { + if (typeof status === "string") { + return effectiveCredentialStatus( + { credentialId: credential.credentialId, status, changedAt: now }, + credential.expiresAt, + now, + ); + } + return effectiveCredentialStatus(status, credential.expiresAt, now); +} + +export function verifyAssociationCredential( + input: VerifyAssociationCredentialInput, +): CoreVerificationResult { + const now = input.now ?? new Date().toISOString(); + const currentStatus = statusRecord(input.credential, input.status, now); + if (input.credential.agentId !== input.agentId) { + return result(input, "FAIL", "INVALID_REQUEST", currentStatus); + } + if ( + input.credential.purpose !== input.purpose + || input.credential.scope.length === 0 + ) { + return result(input, "FAIL", "INVALID_REQUEST", currentStatus); + } + if ( + Date.parse(now) < Date.parse(input.credential.validFrom) + || currentStatus !== "ACTIVE" + ) { + return result( + input, + "FAIL", + "ASSOCIATION_CREDENTIAL_NOT_ACTIVE", + currentStatus, + ); + } + const trace = mappingTraceFromCredential(input.credential); + if (trace === undefined || !validateMappingTrace(trace).valid) { + return result(input, "FAIL", "INVALID_REQUEST", currentStatus); + } + if (!verifyCredentialProof(input)) { + return result(input, "FAIL", "ASSOCIATION_PROOF_INVALID", currentStatus); + } + return result(input, "PASS", "VERIFIED", currentStatus); +} + +export interface VerifyAssociatedCreditInput + extends VerifyAssociationCredentialInput { + authorization?: CreditQueryAuthorization; + authorizationRequest?: AuthorizationEvaluationRequest; + creditAssertionValid?: boolean; + creditAssertionAvailable?: boolean; + mappingPolicySupported?: boolean; + reviewRequired?: boolean; +} + +export function verifyAssociatedCredit( + input: VerifyAssociatedCreditInput, +): CoreVerificationResult { + const base = verifyAssociationCredential(input); + if (base.result !== "PASS") return base; + + if (input.authorization === undefined) { + return { + ...base, + result: "FAIL", + reasonCode: "AUTHORIZATION_REQUIRED", + }; + } + const verificationNow = input.now ?? new Date().toISOString(); + if (input.authorization.status === "REVOKED") { + return { ...base, result: "FAIL", reasonCode: "AUTHORIZATION_REVOKED" }; + } + if ( + input.authorization.status === "EXPIRED" + || Date.parse(verificationNow) < Date.parse(input.authorization.validFrom) + || Date.parse(verificationNow) >= Date.parse(input.authorization.expiresAt) + ) { + return { ...base, result: "FAIL", reasonCode: "AUTHORIZATION_EXPIRED" }; + } + if ( + input.authorization.subjectId !== input.credential.subjectId + || !input.authorization.agentIds.includes(input.credential.agentId) + || input.authorization.purpose !== input.purpose + ) { + return { + ...base, + result: "FAIL", + reasonCode: "AUTHORIZATION_SCOPE_MISMATCH", + }; + } + if (input.authorizationRequest !== undefined) { + const decision = evaluateAuthorization( + input.authorization, + input.authorizationRequest, + input.now, + ); + if (!decision.allowed) { + return { ...base, result: "FAIL", reasonCode: decision.reasonCode }; + } + } + if (input.reviewRequired === true) { + return { ...base, result: "REVIEW_REQUIRED", reasonCode: "REVIEW_REQUIRED" }; + } + if (input.creditAssertionAvailable === false) { + return { + ...base, + result: "INCONCLUSIVE", + reasonCode: "CREDIT_ASSERTION_UNAVAILABLE", + }; + } + if (input.creditAssertionValid !== true) { + return { ...base, result: "FAIL", reasonCode: "CREDIT_ASSERTION_INVALID" }; + } + if (input.mappingPolicySupported !== true) { + return { + ...base, + result: "INCONCLUSIVE", + reasonCode: "MAPPING_POLICY_UNSUPPORTED", + }; + } + + const allowedItems = new Set(input.authorization.allowedDataItems); + const requestedItems = input.requestedDataItems ?? []; + const mayDiscloseValue = requestedItems.includes("associatedCreditValue") + && allowedItems.has("associatedCreditValue"); + const mayDisclosePolicy = allowedItems.has("mappingPolicy"); + if (!mayDiscloseValue || !mayDisclosePolicy) { + return { + ...base, + result: "FAIL", + reasonCode: "AUTHORIZATION_SCOPE_MISMATCH", + }; + } + + return { + ...base, + result: "PASS", + reasonCode: "VERIFIED", + completedVerificationLevel: "ASSOCIATED_CREDIT", + creditSource: "ASSOCIATED_CREDIT", + subjectCreditAssertionValid: true, + associatedCreditValue: structuredClone(input.credential.associatedCreditValue), + mappingPolicy: structuredClone(input.credential.mappingPolicy), + }; +} + +export function createVerificationRecord(input: { + requestId: string; + agentId: string; + verification: CoreVerificationResult; + generatedAt?: string; + ttlSeconds?: number; + statusQuery?: VerificationResponse["statusQuery"]; + responsePrivateKey: KeyMaterial; + responseKeyId?: string; +}): VerificationResponse { + const generatedAt = input.generatedAt ?? new Date().toISOString(); + const expiresAt = new Date( + Date.parse(generatedAt) + (input.ttlSeconds ?? 300) * 1_000, + ).toISOString(); + const unsigned: Omit = { + requestId: input.requestId, + verificationRecordId: `verification-${randomUUID()}`, + agentId: input.agentId, + completedLevel: input.verification.completedVerificationLevel, + result: input.verification.result, + reasonCode: input.verification.reasonCode, + credentialStatus: input.verification.credentialStatus, + scope: [...input.verification.scope], + ...(input.verification.creditSource === undefined + ? {} + : { creditSource: input.verification.creditSource }), + ...(input.verification.associatedCreditValue === undefined + ? {} + : { associatedCreditValue: structuredClone(input.verification.associatedCreditValue) }), + ...(input.verification.subjectCreditAssertionValid === undefined + ? {} + : { subjectCreditAssertionValid: input.verification.subjectCreditAssertionValid }), + ...(input.verification.mappingPolicy === undefined + ? {} + : { mappingPolicy: structuredClone(input.verification.mappingPolicy) }), + generatedAt, + expiresAt, + ...(input.statusQuery === undefined ? {} : { statusQuery: input.statusQuery }), + purposeLimited: true, + }; + return signVerificationResponse( + unsigned, + input.responsePrivateKey, + input.responseKeyId, + ); +} + +export function verificationRequestSigningPayload( + request: VerificationRequest, +): Record { + const payload = structuredClone(request) as unknown as Record; + delete payload.requestProof; + return payload; +} + +export interface VerifyVerificationRequestInput { + request: VerificationRequest; + relyingPartyPublicKey: PublicKeyReference | KeyMaterial; + now?: string; + maxClockSkewSeconds?: number; + consumeAntiReplay?: (key: string) => boolean; +} + +export function verifyVerificationRequest( + input: VerifyVerificationRequestInput, +): { valid: boolean; reasonCode: ReasonCode } { + const { request } = input; + if ( + request.messageVersion !== "reference-v1" + || request.requestId.trim() === "" + || request.relyingPartyId.trim() === "" + || request.agentId.trim() === "" + || request.purpose.trim() === "" + || request.requestedDataItems.length === 0 + || Object.keys(request.businessContext).length === 0 + ) { + return { valid: false, reasonCode: "INVALID_REQUEST" }; + } + if ( + request.verificationLevel === "ASSOCIATED_CREDIT" + && ( + request.subjectCreditAssertionRef === undefined + || request.authorizationId === undefined + ) + ) { + return { valid: false, reasonCode: "AUTHORIZATION_REQUIRED" }; + } + const requestedAt = Date.parse(request.requestedAt); + const currentTime = Date.parse(input.now ?? new Date().toISOString()); + const skewMilliseconds = (input.maxClockSkewSeconds ?? 300) * 1_000; + if ( + Number.isNaN(requestedAt) + || Number.isNaN(currentTime) + || Math.abs(currentTime - requestedAt) > skewMilliseconds + ) { + return { valid: false, reasonCode: "REQUEST_EXPIRED" }; + } + const nonce = request.antiReplay.nonce; + const idempotencyKey = request.antiReplay.idempotencyKey; + if ( + (nonce === undefined || nonce.length < 16) + && (idempotencyKey === undefined || idempotencyKey.length < 8) + ) { + return { valid: false, reasonCode: "INVALID_REQUEST" }; + } + if (!verifyCanonicalSignature( + verificationRequestSigningPayload(request), + request.requestProof, + input.relyingPartyPublicKey, + )) { + return { valid: false, reasonCode: "REQUEST_PROOF_INVALID" }; + } + const replayKey = [ + request.relyingPartyId, + request.requestId, + nonce ?? "", + idempotencyKey ?? "", + ].join(":"); + if (input.consumeAntiReplay !== undefined && !input.consumeAntiReplay(replayKey)) { + return { valid: false, reasonCode: "REPLAY_DETECTED" }; + } + return { valid: true, reasonCode: "VERIFIED" }; +} + +export function verificationResponseSigningPayload( + response: Omit | VerificationResponse, +): Record { + const payload = structuredClone(response) as unknown as Record; + delete payload.responseProof; + return payload; +} + +export function signVerificationResponse( + response: Omit, + privateKey: KeyMaterial, + keyId?: string, +): VerificationResponse { + const responseProof: SignatureProof = signCanonical( + verificationResponseSigningPayload(response), + privateKey, + keyId, + ); + return { ...structuredClone(response), responseProof }; +} + +export function verifyVerificationResponseProof( + response: VerificationResponse, + publicKey: PublicKeyReference | KeyMaterial, +): boolean { + return verifyCanonicalSignature( + verificationResponseSigningPayload(response), + response.responseProof, + publicKey, + ); +} diff --git a/code/samples/tsd-crd-reference/src/http/server.ts b/code/samples/tsd-crd-reference/src/http/server.ts new file mode 100644 index 0000000..c6dd05c --- /dev/null +++ b/code/samples/tsd-crd-reference/src/http/server.ts @@ -0,0 +1,226 @@ +import { createServer as createNodeServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { pathToFileURL } from "node:url"; +import { createReferenceSuite, type ReferenceSuite } from "../application/reference-suite.ts"; + +const MAX_BODY_BYTES = 1024 * 1024; + +class HttpError extends Error { + statusCode: number; + code: string; + + constructor(statusCode: number, code: string, message: string) { + super(message); + this.statusCode = statusCode; + this.code = code; + } +} + +async function readJson(request: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let size = 0; + + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) { + throw new HttpError(413, "PAYLOAD_TOO_LARGE", "Request body exceeds 1 MiB"); + } + chunks.push(buffer); + } + + if (chunks.length === 0) { + return {}; + } + + try { + const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new Error("JSON body must be an object"); + } + return value as Record; + } catch (error) { + throw new HttpError( + 400, + "INVALID_JSON", + error instanceof Error ? error.message : "Invalid JSON body", + ); + } +} + +function sendJson(response: ServerResponse, statusCode: number, body: unknown): void { + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + response.end(`${JSON.stringify(body, null, 2)}\n`); +} + +function methodNotAllowed(response: ServerResponse): void { + sendJson(response, 405, { + error: { code: "METHOD_NOT_ALLOWED", message: "Method not allowed" }, + }); +} + +export function createSandboxServer(suite: ReferenceSuite = createReferenceSuite()) { + return createNodeServer(async (request, response) => { + const method = request.method ?? "GET"; + const url = new URL(request.url ?? "/", "http://localhost"); + + try { + if (url.pathname === "/health") { + if (method !== "GET") return methodNotAllowed(response); + return sendJson(response, 200, { + status: "ok", + service: "tsd-crd-reference-sandbox", + profile: "reference-v1", + }); + } + + if (url.pathname === "/v1/association-applications") { + if (method !== "POST") return methodNotAllowed(response); + const result = suite.createAssociationRequest(await readJson(request)); + return sendJson(response, 201, suite.protocolAssociationApplication(result)); + } + + const preparationMatch = url.pathname.match( + /^\/v1\/association-applications\/([^/]+)\/preparations$/, + ); + if (preparationMatch) { + if (method !== "POST") return methodNotAllowed(response); + const result = suite.prepareDirectConfirmation( + decodeURIComponent(preparationMatch[1]), + ); + return sendJson(response, 200, result); + } + + const confirmationMatch = url.pathname.match( + /^\/v1\/association-applications\/([^/]+)\/confirmations$/, + ); + if (confirmationMatch) { + if (method !== "POST") return methodNotAllowed(response); + const result = suite.confirmAssociation( + decodeURIComponent(confirmationMatch[1]), + await readJson(request), + ); + return sendJson(response, 201, result.credential); + } + + const credentialMatch = url.pathname.match( + /^\/v1\/association-credentials\/([^/]+)$/, + ); + if (credentialMatch) { + if (method !== "GET") return methodNotAllowed(response); + const result = suite.getCredential(decodeURIComponent(credentialMatch[1])); + if (!result) { + throw new HttpError(404, "ASSOCIATION_CREDENTIAL_NOT_FOUND", "Credential not found"); + } + return sendJson(response, 200, result); + } + + const statusMatch = url.pathname.match( + /^\/v1\/association-credentials\/([^/]+)\/status$/, + ); + if (statusMatch) { + if (method !== "GET") return methodNotAllowed(response); + const result = suite.getCredentialStatus(decodeURIComponent(statusMatch[1])); + if (!result) { + throw new HttpError(404, "ASSOCIATION_CREDENTIAL_NOT_FOUND", "Credential not found"); + } + return sendJson(response, 200, result); + } + + const lifecycleMatch = url.pathname.match( + /^\/v1\/association-credentials\/([^/]+)\/(suspensions|resumptions|revocations)$/, + ); + if (lifecycleMatch) { + if (method !== "POST") return methodNotAllowed(response); + const targetByOperation = { + suspensions: "SUSPENDED", + resumptions: "ACTIVE", + revocations: "REVOKED", + } as const; + const body = await readJson(request); + const credentialId = decodeURIComponent(lifecycleMatch[1]); + const targetStatus = targetByOperation[ + lifecycleMatch[2] as keyof typeof targetByOperation + ]; + const result = suite.changeCredentialStatusFromRequest( + credentialId, + targetStatus, + body, + ); + return sendJson(response, 200, result); + } + + if (url.pathname === "/v1/credit-query-authorizations") { + if (method !== "POST") return methodNotAllowed(response); + const result = suite.createQueryAuthorization(await readJson(request)); + return sendJson(response, 201, result); + } + + const authorizationRevokeMatch = url.pathname.match( + /^\/v1\/credit-query-authorizations\/([^/]+)\/revocations$/, + ); + if (authorizationRevokeMatch) { + if (method !== "POST") return methodNotAllowed(response); + const body = await readJson(request); + const authorizationId = decodeURIComponent(authorizationRevokeMatch[1]); + const result = suite.revokeQueryAuthorizationFromRequest( + authorizationId, + body, + ); + return sendJson(response, 200, result); + } + + if (url.pathname === "/v1/verifications") { + if (method !== "POST") return methodNotAllowed(response); + const result = suite.verify(await readJson(request)); + return sendJson(response, 200, result); + } + + if (url.pathname === "/v1/demo/reset") { + if (method !== "POST") return methodNotAllowed(response); + suite.reset(); + return sendJson(response, 200, { reset: true }); + } + + return sendJson(response, 404, { + error: { code: "NOT_FOUND", message: "Route not found" }, + }); + } catch (error) { + if (error instanceof HttpError) { + return sendJson(response, error.statusCode, { + error: { code: error.code, message: error.message }, + }); + } + + const message = error instanceof Error ? error.message : "Unexpected error"; + const isNotFound = /not found/i.test(message); + return sendJson(response, isNotFound ? 404 : 400, { + error: { + code: isNotFound ? "NOT_FOUND" : "INVALID_REQUEST", + message, + }, + }); + } + }); +} + +export function startSandboxServer(options: { port?: number; host?: string } = {}) { + const port = options.port ?? Number(process.env.PORT ?? 8787); + const host = options.host ?? process.env.HOST ?? "127.0.0.1"; + const server = createSandboxServer(); + server.listen(port, host, () => { + const address = server.address(); + const actualPort = typeof address === "object" && address ? address.port : port; + process.stdout.write( + `ACT TSD-CRD reference sandbox listening on http://${host}:${actualPort}\n`, + ); + }); + return server; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + startSandboxServer(); +} diff --git a/code/samples/tsd-crd-reference/test/application-validation.test.ts b/code/samples/tsd-crd-reference/test/application-validation.test.ts new file mode 100644 index 0000000..699a532 --- /dev/null +++ b/code/samples/tsd-crd-reference/test/application-validation.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { demoAssociationRequest } from "../src/adapters/mock-providers.ts"; +import { + antiReplayValue, + validateAssociationApplication, +} from "../src/application/validation.ts"; + +test("validates the reference-v1 demo association application", () => { + const application = validateAssociationApplication(demoAssociationRequest()); + assert.equal(application.confirmationMethod, "ATTESTED_CONFIRMATION"); + assert.equal(application.agentId, "agent-demo-shopping"); + assert.equal( + antiReplayValue(application.antiReplay), + "nonce:demo-association-nonce-001", + ); +}); + +test("rejects a short anti-replay nonce", () => { + const input = { + ...demoAssociationRequest(), + antiReplay: { nonce: "short" }, + }; + assert.throws( + () => validateAssociationApplication(input), + /antiReplay requires nonce or idempotencyKey/, + ); +}); + +test("rejects an unsupported confirmation method", () => { + const input = { + ...demoAssociationRequest(), + confirmationMethod: "AGENT_SIGNATURE", + }; + assert.throws( + () => validateAssociationApplication(input), + /Unsupported confirmationMethod/, + ); +}); diff --git a/code/samples/tsd-crd-reference/test/core.test.ts b/code/samples/tsd-crd-reference/test/core.test.ts new file mode 100644 index 0000000..3eef587 --- /dev/null +++ b/code/samples/tsd-crd-reference/test/core.test.ts @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canonicalize, + createAuthorization, + createMappingTrace, + evaluateAuthorization, + generateEd25519KeyPair, + issueAttestedCredential, + issueDirectCredential, + issueDirectCredentialFromConfirmation, + revokeAuthorization, + signCanonical, + transitionStatus, + verifyAssociationCredential, + verifyAuthorizationProof, + verifyAssociatedCredit, + verifyCanonicalSignature, + verifyCredentialProof, + type AssociationCredential, +} from "../src/core/index.ts"; + +const now = "2026-08-12T00:00:00.000Z"; +const issuer = generateEd25519KeyPair("issuer#1"); +const subject = generateEd25519KeyPair("subject#1"); + +function credentialDraft(overrides: Record = {}) { + return { + credentialId: "credential-1", + credentialVersion: "reference-v1", + associationApplicationId: "application-1", + agentId: "agent-1", + subjectId: "subject-1", + associationRole: "OPERATOR", + issuerId: "issuer-1", + relationshipEvidenceRef: "evidence-1", + subjectCreditAssertionRef: "assertion-1", + associatedCreditValue: { band: "A", limit: "1000" }, + creditSource: "ASSOCIATED_CREDIT", + mappingPolicy: { id: "demo-map", version: "1" }, + confirmationStatement: "I confirm the stated association.", + purpose: "PURCHASE_RISK_CHECK", + scope: ["shopping"], + associationApplicationRequestedAt: "2026-01-01T00:00:00.000Z", + associationApplicationAntiReplay: { nonce: "core-direct-signature-nonce-001" }, + issuedAt: now, + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + statusQuery: { uri: "https://issuer.example/status/credential-1" }, + ...overrides, + }; +} + +test("canonical JSON is deterministic and rejects non-interoperable values", () => { + assert.equal( + canonicalize({ z: 1, a: { y: true, x: [3, 2, 1] } }), + '{"a":{"x":[3,2,1],"y":true},"z":1}', + ); + assert.throws(() => canonicalize(-0), /negative zero/); + assert.throws(() => canonicalize({ value: undefined }), /cannot serialize undefined/); +}); + +test("Ed25519 signs canonical values", () => { + const left = { z: 1, a: "same" }; + const right = { a: "same", z: 1 }; + const proof = signCanonical(left, issuer.privateKey, issuer.keyId); + assert.equal(verifyCanonicalSignature(right, proof, issuer.publicKey), true); + assert.equal( + verifyCanonicalSignature({ ...right, z: 2 }, proof, issuer.publicKey), + false, + ); +}); + +test("MAP creates the required traceability triad", () => { + const trace = createMappingTrace({ + subjectCreditAssertionRef: "assertion-1", + associatedCreditValue: { band: "A" }, + mappingPolicy: { id: "map-1", version: "1" }, + }); + assert.equal(trace.creditSource, "ASSOCIATED_CREDIT"); +}); + +test("ASC issues and verifies attested and direct credentials", () => { + const attested = issueAttestedCredential({ + credential: credentialDraft() as never, + issuerPrivateKey: issuer.privateKey, + }); + assert.equal(attested.confirmationMethod, "ATTESTED_CONFIRMATION"); + assert.equal( + verifyCredentialProof({ credential: attested, issuerPublicKey: issuer.publicKey }), + true, + ); + + const direct = issueDirectCredential({ + credential: credentialDraft() as never, + subjectPrivateKey: subject.privateKey, + subjectPublicKey: subject.publicKey, + issuerPrivateKey: issuer.privateKey, + }); + assert.equal(direct.confirmationMethod, "DIRECT_SIGNATURE"); + assert.equal( + verifyCredentialProof({ credential: direct, issuerPublicKey: issuer.publicKey }), + true, + ); + const subjectProof = { + subjectPublicKey: direct.subjectPublicKey!, + subjectSignature: direct.subjectSignature!, + subjectSignatureAlgorithm: direct.subjectSignatureAlgorithm!, + }; + const issuedFromProof = issueDirectCredentialFromConfirmation({ + credential: credentialDraft() as never, + ...subjectProof, + issuerPrivateKey: issuer.privateKey, + }); + assert.equal( + verifyCredentialProof({ + credential: issuedFromProof, + issuerPublicKey: issuer.publicKey, + }), + true, + ); + const tampered = { + ...direct, + purpose: "DIFFERENT_PURPOSE", + } satisfies AssociationCredential; + assert.equal( + verifyCredentialProof({ credential: tampered, issuerPublicKey: issuer.publicKey }), + false, + ); +}); + +test("LCM enforces allowed transitions and terminal states", () => { + assert.equal(transitionStatus("ACTIVE", "SUSPENDED", now).status, "SUSPENDED"); + assert.throws( + () => transitionStatus("REVOKED", "ACTIVE", now), + /Invalid credential status transition/, + ); +}); + +test("AUTH enforces per-request scope and revocation", () => { + const authorization = createAuthorization({ + authorizationId: "authorization-1", + mode: "PER_REQUEST", + subjectId: "subject-1", + relyingPartyIds: ["rp-1"], + agentIds: ["agent-1"], + purpose: "PURCHASE_RISK_CHECK", + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "request-1", + subjectPrivateKey: subject.privateKey, + subjectKeyId: subject.keyId, + }); + const request = { + requestId: "request-1", + relyingPartyId: "rp-1", + agentId: "agent-1", + verificationLevel: "ASSOCIATED_CREDIT" as const, + purpose: "PURCHASE_RISK_CHECK", + requestedDataItems: ["associatedCreditValue"], + }; + const allowed = evaluateAuthorization(authorization, request, now); + assert.equal(allowed.allowed, true); + assert.equal(allowed.runtimeState.useCount, 1); + assert.equal( + evaluateAuthorization( + allowed.authorization, + request, + now, + allowed.runtimeState.usageTimestamps.map((usedAt) => ({ usedAt })), + ).allowed, + false, + ); + assert.equal( + evaluateAuthorization(revokeAuthorization(authorization, now), request, now).reasonCode, + "AUTHORIZATION_REVOKED", + ); + assert.equal(verifyAuthorizationProof(authorization, subject.publicKey), true); + assert.equal( + verifyAuthorizationProof(revokeAuthorization(authorization, now), subject.publicKey), + true, + ); +}); + +test("AUTH enforces platform delegate identity and frequency", () => { + const authorization = createAuthorization({ + authorizationId: "authorization-platform-1", + mode: "PLATFORM_DELEGATED", + subjectId: "subject-1", + relyingPartyIds: ["rp-1"], + platformDelegateId: "platform-1", + agentIds: ["agent-1"], + purpose: "PURCHASE_RISK_CHECK", + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + frequencyLimit: { + maxRequestsPerWindow: 1, + windowDurationSeconds: 60, + }, + subjectPrivateKey: subject.privateKey, + subjectKeyId: subject.keyId, + }); + const request = { + requestId: "platform-request-1", + relyingPartyId: "rp-1", + platformDelegateId: "platform-1", + agentId: "agent-1", + verificationLevel: "ASSOCIATED_CREDIT" as const, + purpose: "PURCHASE_RISK_CHECK", + requestedDataItems: ["associatedCreditValue", "mappingPolicy"], + }; + const first = evaluateAuthorization(authorization, request, now); + assert.equal(first.allowed, true); + const second = evaluateAuthorization( + authorization, + request, + now, + first.runtimeState.usageTimestamps.map((usedAt) => ({ usedAt })), + ); + assert.equal(second.allowed, false); + assert.equal(second.reasonCode, "AUTHORIZATION_SCOPE_MISMATCH"); + assert.equal( + evaluateAuthorization( + authorization, + { ...request, platformDelegateId: "platform-wrong" }, + now, + ).allowed, + false, + ); +}); + +test("VER separates credential verification from authorized minimal disclosure", () => { + const credential = issueAttestedCredential({ + credential: credentialDraft() as never, + issuerPrivateKey: issuer.privateKey, + }); + const base = { + credential, + issuerPublicKey: issuer.publicKey, + status: "ACTIVE" as const, + agentId: "agent-1", + purpose: "PURCHASE_RISK_CHECK", + requestedDataItems: ["associatedCreditValue"], + now, + }; + const levelOne = verifyAssociationCredential(base); + assert.equal(levelOne.result, "PASS"); + assert.equal(levelOne.associatedCreditValue, undefined); + + const missingAuthorization = verifyAssociatedCredit({ + ...base, + creditAssertionValid: true, + mappingPolicySupported: true, + }); + assert.equal(missingAuthorization.reasonCode, "AUTHORIZATION_REQUIRED"); + + const authorization = createAuthorization({ + authorizationId: "authorization-verification", + mode: "PER_REQUEST", + subjectId: "subject-1", + relyingPartyIds: ["rp-1"], + agentIds: ["agent-1"], + purpose: "PURCHASE_RISK_CHECK", + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "request-verification", + subjectPrivateKey: subject.privateKey, + subjectKeyId: subject.keyId, + }); + const levelTwo = verifyAssociatedCredit({ + ...base, + authorization, + creditAssertionValid: true, + mappingPolicySupported: true, + }); + assert.equal(levelTwo.result, "PASS"); + assert.deepEqual(levelTwo.associatedCreditValue, { band: "A", limit: "1000" }); + assert.deepEqual(levelTwo.mappingPolicy, { id: "demo-map", version: "1" }); +}); diff --git a/code/samples/tsd-crd-reference/test/in-memory-store.test.ts b/code/samples/tsd-crd-reference/test/in-memory-store.test.ts new file mode 100644 index 0000000..31aa211 --- /dev/null +++ b/code/samples/tsd-crd-reference/test/in-memory-store.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { InMemoryStore } from "../src/adapters/in-memory-store.ts"; + +test("replay keys are consumed exactly once", () => { + const store = new InMemoryStore(); + assert.equal(store.consumeReplayKey("application", "nonce-1"), true); + assert.equal(store.consumeReplayKey("application", "nonce-1"), false); + assert.equal(store.consumeReplayKey("verification", "nonce-1"), true); +}); + +test("stored values are cloned on read", () => { + const store = new InMemoryStore(); + store.saveAssociationRequest({ + applicationId: "app-1", + status: "PENDING", + antiReplay: { nonce: "0123456789abcdef" }, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }); + const first = store.getAssociationRequest("app-1"); + assert.ok(first); + first.status = "REVOKED"; + assert.equal(store.getAssociationRequest("app-1")?.status, "PENDING"); +}); diff --git a/code/samples/tsd-crd-reference/test/reference-suite.test.ts b/code/samples/tsd-crd-reference/test/reference-suite.test.ts new file mode 100644 index 0000000..fdd74cc --- /dev/null +++ b/code/samples/tsd-crd-reference/test/reference-suite.test.ts @@ -0,0 +1,631 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { demoAssociationRequest } from "../src/adapters/mock-providers.ts"; +import { createReferenceSuite } from "../src/application/reference-suite.ts"; +import { + createAuthorization, + generateEd25519KeyPair, + signCanonical, + verificationRequestSigningPayload, + verifyCanonicalSignature, + verifyCredentialProof, + verifyVerificationResponseProof, +} from "../src/core/index.ts"; + +const NOW = "2026-08-12T00:00:00.000Z"; +const relyingPartyKeys = generateEd25519KeyPair("relying-party-demo-shop#key-1"); +const subjectKeys = generateEd25519KeyPair("subject-demo-alice#key-1"); + +function strictSuite(now = NOW) { + return createReferenceSuite({ + now: () => now, + resolveRelyingPartyPublicKey: (relyingPartyId, keyId) => + relyingPartyId === "relying-party-demo-shop" && keyId === relyingPartyKeys.keyId + ? relyingPartyKeys.publicKey + : undefined, + resolveSubjectPublicKey: (subjectId, keyId) => + subjectId === "subject-demo-alice" && keyId === subjectKeys.keyId + ? subjectKeys.publicKey + : undefined, + }); +} + +function protocolVerificationRequest( + input: Record, +): Record { + const requestId = String(input.requestId); + const unsigned = { + messageVersion: "reference-v1", + relyingPartyId: "relying-party-demo-shop", + businessContext: { orderId: `order-${requestId}` }, + requestedAt: NOW, + antiReplay: { nonce: `verification-nonce-${requestId}` }, + requestProof: { + signatureAlgorithm: "Ed25519", + keyId: relyingPartyKeys.keyId, + signatureValue: "PENDING", + }, + ...input, + }; + const proof = signCanonical( + verificationRequestSigningPayload(unsigned as never), + relyingPartyKeys.privateKey, + relyingPartyKeys.keyId, + ); + return { ...unsigned, requestProof: proof }; +} + +function signedAuthorization(input: Record): Record { + return createAuthorization({ + ...input, + mode: String(input.mode) as "PER_REQUEST" | "PLATFORM_DELEGATED", + authorizationId: String(input.authorizationId), + subjectId: String(input.subjectId), + relyingPartyIds: input.relyingPartyIds as string[], + agentIds: input.agentIds as string[], + purpose: String(input.purpose), + allowedDataItems: input.allowedDataItems as string[], + validFrom: String(input.validFrom), + expiresAt: String(input.expiresAt), + boundRequestId: typeof input.boundRequestId === "string" ? input.boundRequestId : undefined, + platformDelegateId: typeof input.platformDelegateId === "string" + ? input.platformDelegateId + : undefined, + frequencyLimit: input.frequencyLimit as never, + subjectPrivateKey: subjectKeys.privateKey, + subjectKeyId: subjectKeys.keyId, + }) as unknown as Record; +} + +function signedSubjectRequest(input: Record): Record { + const unsigned = { + ...input, + requestProof: { + signatureAlgorithm: "Ed25519", + keyId: subjectKeys.keyId, + signatureValue: "PENDING", + }, + }; + const payload = structuredClone(unsigned); + delete payload.requestProof; + return { + ...unsigned, + requestProof: signCanonical(payload, subjectKeys.privateKey, subjectKeys.keyId), + }; +} + +test("ATTESTED_CONFIRMATION issues a credential with outer signature only", () => { + const suite = createReferenceSuite({ now: () => NOW }); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + + assert.equal(credential.confirmationMethod, "ATTESTED_CONFIRMATION"); + assert.equal(typeof credential.issuerSignature, "string"); + assert.equal(credential.issuerSignatureAlgorithm, "Ed25519"); + assert.equal(credential.subjectPublicKey, undefined); + assert.equal(credential.subjectSignature, undefined); + assert.equal(credential.subjectSignatureAlgorithm, undefined); + assert.equal(suite.getCredentialStatus(credential.credentialId)?.status, "ACTIVE"); +}); + +test("rejects replayed association applications", () => { + const suite = createReferenceSuite({ now: () => NOW }); + suite.createAssociationRequest(demoAssociationRequest()); + const replay = { + ...demoAssociationRequest(), + applicationId: "association-application-demo-002", + }; + assert.throws( + () => suite.createAssociationRequest(replay), + /REPLAY_DETECTED/, + ); +}); + +test("DIRECT_SIGNATURE accepts a locally created subject signature and adds the outer signature", () => { + const suite = strictSuite(); + const applicationInput = { + ...demoAssociationRequest(), + applicationId: "association-application-direct-001", + confirmationMethod: "DIRECT_SIGNATURE", + antiReplay: { nonce: "direct-signature-nonce-001" }, + }; + const application = suite.createAssociationRequest(applicationInput); + const preparation = suite.prepareDirectConfirmation(application.applicationId); + const proof = signCanonical( + preparation.signingPayload, + subjectKeys.privateKey, + subjectKeys.keyId, + ); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "DIRECT_SIGNATURE", + subjectPublicKey: subjectKeys.publicKey, + subjectSignature: proof.signatureValue, + subjectSignatureAlgorithm: proof.signatureAlgorithm, + }); + + assert.equal(credential.confirmationMethod, "DIRECT_SIGNATURE"); + assert.equal(credential.subjectSignature, proof.signatureValue); + assert.equal(credential.subjectSignatureAlgorithm, "Ed25519"); + assert.equal(credential.issuerSignatureAlgorithm, "Ed25519"); + assert.equal( + credential.associationApplicationRequestedAt, + applicationInput.requestedAt, + ); + assert.deepEqual( + credential.associationApplicationAntiReplay, + applicationInput.antiReplay, + ); + assert.equal( + verifyCredentialProof({ + credential: { + ...credential, + associationApplicationAntiReplay: { nonce: "tampered-direct-nonce-001" }, + }, + issuerPublicKey: suite.issuerPublicKey, + }), + false, + ); +}); + +test("associated credit requires authorization and discloses only allowed items", () => { + const suite = strictSuite(); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + + const baseRequest = { + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "ASSOCIATED_CREDIT", + relyingPartyId: "relying-party-demo-shop", + subjectCreditAssertionRef: credential.subjectCreditAssertionRef, + purpose: credential.purpose, + requestedDataItems: ["associatedCreditValue", "mappingPolicy"], + }; + const denied = suite.verify(protocolVerificationRequest({ + requestId: "verify-denied", + ...baseRequest, + authorizationId: "authorization-missing", + })); + assert.equal(denied.reasonCode, "AUTHORIZATION_REQUIRED"); + + const authorization = suite.createQueryAuthorization(signedAuthorization({ + authorizationId: "authorization-once", + mode: "PER_REQUEST", + subjectId: credential.subjectId, + relyingPartyIds: ["relying-party-demo-shop"], + agentIds: [credential.agentId], + purpose: credential.purpose, + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "verify-allowed", + })); + const allowed = suite.verify(protocolVerificationRequest({ + requestId: "verify-allowed", + ...baseRequest, + authorizationId: authorization.authorizationId, + })); + assert.equal(allowed.result, "PASS"); + assert.equal(allowed.completedLevel, "ASSOCIATED_CREDIT"); + assert.equal(allowed.purposeLimited, true); + assert.equal(allowed.responseProof.signatureAlgorithm, "Ed25519"); + assert.equal( + verifyVerificationResponseProof(allowed, suite.issuerPublicKey), + true, + ); + assert.ok("associatedCreditValue" in allowed); + assert.equal("mappingPolicy" in allowed, true); +}); + +test("revoked credentials cannot produce a new PASS result", () => { + const suite = strictSuite(); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + suite.changeCredentialStatusFromRequest( + credential.credentialId, + "REVOKED", + signedSubjectRequest({ + requestId: "status-revoke-for-verification-001", + credentialId: credential.credentialId, + targetStatus: "REVOKED", + reasonCode: "SUBJECT_REQUEST", + requestedBy: credential.subjectId, + requestedAt: NOW, + antiReplay: { nonce: "status-revoke-for-verification-nonce-001" }, + }), + ); + const verification = suite.verify(protocolVerificationRequest({ + requestId: "verify-revoked", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "CREDENTIAL", + relyingPartyId: "relying-party-demo-shop", + purpose: credential.purpose, + requestedDataItems: ["credentialStatus"], + })); + assert.equal(verification.result, "FAIL"); + assert.equal(verification.reasonCode, "ASSOCIATION_CREDENTIAL_NOT_ACTIVE"); + const status = suite.getCredentialStatus(credential.credentialId)!; + const { statusProof, ...unsignedStatus } = status; + assert.equal(status.statusVersion, 2); + assert.equal( + verifyCanonicalSignature( + unsignedStatus, + statusProof, + suite.issuerPublicKey, + ), + true, + ); +}); + +test("verifies relying-party request proof and rejects replay when a resolver is configured", () => { + const suite = strictSuite(); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + const unsigned = protocolVerificationRequest({ + requestId: "verify-signed-request", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "CREDENTIAL", + purpose: credential.purpose, + requestedDataItems: ["credentialStatus"], + requestProof: { + signatureAlgorithm: "Ed25519", + keyId: relyingPartyKeys.keyId, + signatureValue: "PENDING", + }, + }); + const proof = signCanonical( + verificationRequestSigningPayload(unsigned as never), + relyingPartyKeys.privateKey, + relyingPartyKeys.keyId, + ); + const signed = { ...unsigned, requestProof: proof }; + + assert.equal(suite.verify(signed).result, "PASS"); + assert.throws(() => suite.verify(signed), /REPLAY_DETECTED/); +}); + +test("validates lifecycle and authorization revocation envelopes", () => { + const suite = strictSuite(); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + const authorization = suite.createQueryAuthorization(signedAuthorization({ + authorizationId: "authorization-revoke-envelope-001", + mode: "PER_REQUEST", + subjectId: credential.subjectId, + relyingPartyIds: ["relying-party-demo-shop"], + agentIds: [credential.agentId], + purpose: credential.purpose, + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "unused-request", + })); + const statusRequest = signedSubjectRequest({ + requestId: "status-revoke-request-001", + credentialId: credential.credentialId, + targetStatus: "REVOKED", + reasonCode: "SUBJECT_REQUEST", + requestedBy: credential.subjectId, + requestedAt: NOW, + antiReplay: { nonce: "status-revoke-request-nonce-001" }, + }); + assert.equal( + suite.changeCredentialStatusFromRequest( + credential.credentialId, + "REVOKED", + statusRequest, + ).status, + "REVOKED", + ); + assert.throws( + () => suite.changeCredentialStatusFromRequest( + credential.credentialId, + "REVOKED", + statusRequest, + ), + /REPLAY_DETECTED/, + ); + + const revocationRequest = signedSubjectRequest({ + requestId: "authorization-revoke-request-001", + authorizationId: authorization.authorizationId, + subjectId: authorization.subjectId, + reasonCode: "SUBJECT_REQUEST", + requestedAt: NOW, + antiReplay: { nonce: "authorization-revoke-nonce-001" }, + }); + assert.equal( + suite.revokeQueryAuthorizationFromRequest( + authorization.authorizationId, + revocationRequest, + ).status, + "REVOKED", + ); +}); + +test("fails closed when trusted proof resolvers are missing or proofs are forged", () => { + const unconfigured = createReferenceSuite({ now: () => NOW }); + const application = unconfigured.createAssociationRequest(demoAssociationRequest()); + const { credential } = unconfigured.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + const signedRequest = protocolVerificationRequest({ + requestId: "strict-default-request-001", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "CREDENTIAL", + purpose: credential.purpose, + requestedDataItems: ["credentialStatus"], + }); + assert.throws( + () => unconfigured.verify(signedRequest), + /trusted relying-party key resolver is required/, + ); + + const configured = strictSuite(); + const configuredApplication = configured.createAssociationRequest(demoAssociationRequest()); + const configuredCredential = configured.confirmAssociation( + configuredApplication.applicationId, + { confirmationMethod: "ATTESTED_CONFIRMATION" }, + ).credential; + const forgedRequest = { + ...protocolVerificationRequest({ + requestId: "forged-request-proof-001", + credential: { credentialId: configuredCredential.credentialId }, + agentId: configuredCredential.agentId, + verificationLevel: "CREDENTIAL", + purpose: configuredCredential.purpose, + requestedDataItems: ["credentialStatus"], + }), + requestProof: { + signatureAlgorithm: "Ed25519", + keyId: relyingPartyKeys.keyId, + signatureValue: "NOT_A_REAL_SIGNATURE", + }, + }; + assert.throws(() => configured.verify(forgedRequest), /REQUEST_PROOF_INVALID/); + + const forgedAuthorization = { + ...signedAuthorization({ + authorizationId: "authorization-forged-proof-001", + mode: "PER_REQUEST", + subjectId: configuredCredential.subjectId, + relyingPartyIds: ["relying-party-demo-shop"], + agentIds: [configuredCredential.agentId], + purpose: configuredCredential.purpose, + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "authorization-forged-proof-request-001", + }), + authorizationProof: { + signatureAlgorithm: "Ed25519", + keyId: subjectKeys.keyId, + signatureValue: "NOT_A_REAL_SIGNATURE", + }, + }; + assert.throws( + () => configured.createQueryAuthorization(forgedAuthorization), + /authorizationProof is invalid/, + ); + + const forgedStatusRequest = { + ...signedSubjectRequest({ + requestId: "status-forged-proof-001", + credentialId: configuredCredential.credentialId, + targetStatus: "SUSPENDED", + reasonCode: "FORGED_REQUEST", + requestedBy: configuredCredential.subjectId, + requestedAt: NOW, + antiReplay: { nonce: "status-forged-proof-nonce-001" }, + }), + requestProof: { + signatureAlgorithm: "Ed25519", + keyId: subjectKeys.keyId, + signatureValue: "NOT_A_REAL_SIGNATURE", + }, + }; + assert.throws( + () => configured.changeCredentialStatusFromRequest( + configuredCredential.credentialId, + "SUSPENDED", + forgedStatusRequest, + ), + /status-change proof is invalid/, + ); +}); + +test("rejects unsigned status rollback after revocation", () => { + const suite = strictSuite(); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + suite.changeCredentialStatusFromRequest( + credential.credentialId, + "REVOKED", + signedSubjectRequest({ + requestId: "status-strict-revoke-001", + credentialId: credential.credentialId, + targetStatus: "REVOKED", + reasonCode: "SUBJECT_REQUEST", + requestedBy: credential.subjectId, + requestedAt: NOW, + antiReplay: { nonce: "status-strict-revoke-nonce-001" }, + }), + ); + assert.throws( + () => suite.store.saveCredentialStatus({ + credentialId: credential.credentialId, + status: "ACTIVE", + statusVersion: 3, + reasonCode: "UNSIGNED_ROLLBACK", + effectiveAt: NOW, + updatedAt: NOW, + statusProof: { + signatureAlgorithm: "Ed25519", + signatureValue: "NOT_A_REAL_SIGNATURE", + }, + }), + /Invalid credential status transition/, + ); +}); + +test("rejects a stored status whose issuer proof is invalid", () => { + const suite = strictSuite(); + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + const status = suite.getCredentialStatus(credential.credentialId)!; + suite.store.credentialStatuses.set(credential.credentialId, { + ...status, + status: "SUSPENDED", + statusVersion: status.statusVersion + 1, + statusProof: { + signatureAlgorithm: "Ed25519", + signatureValue: "NOT_A_REAL_SIGNATURE", + }, + }); + assert.throws( + () => suite.verify(protocolVerificationRequest({ + requestId: "verification-forged-status-001", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "CREDENTIAL", + purpose: credential.purpose, + requestedDataItems: ["credentialStatus"], + })), + /credential status proof is invalid/, + ); +}); + +test("rejects expired subject credit assertions during issuance", () => { + const suite = strictSuite("2031-01-01T00:00:00.000Z"); + const application = suite.createAssociationRequest({ + ...demoAssociationRequest(), + requestedAt: "2031-01-01T00:00:00.000Z", + validFrom: "2031-01-01T00:00:00.000Z", + expiresAt: "2032-01-01T00:00:00.000Z", + }); + assert.throws( + () => suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }), + /CREDIT_ASSERTION_INVALID/, + ); +}); + +test("rejects an expired subject credit assertion during associated-credit verification", () => { + let currentTime = NOW; + const suite = createReferenceSuite({ + now: () => currentTime, + resolveRelyingPartyPublicKey: (relyingPartyId, keyId) => + relyingPartyId === "relying-party-demo-shop" && keyId === relyingPartyKeys.keyId + ? relyingPartyKeys.publicKey + : undefined, + resolveSubjectPublicKey: (subjectId, keyId) => + subjectId === "subject-demo-alice" && keyId === subjectKeys.keyId + ? subjectKeys.publicKey + : undefined, + }); + const application = suite.createAssociationRequest({ + ...demoAssociationRequest(), + expiresAt: "2032-01-01T00:00:00.000Z", + }); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + const authorization = suite.createQueryAuthorization(signedAuthorization({ + authorizationId: "authorization-credit-assertion-expiry-001", + mode: "PER_REQUEST", + subjectId: credential.subjectId, + relyingPartyIds: ["relying-party-demo-shop"], + agentIds: [credential.agentId], + purpose: credential.purpose, + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2032-01-01T00:00:00.000Z", + boundRequestId: "verification-credit-assertion-expiry-001", + })); + currentTime = "2031-01-01T00:00:00.000Z"; + const response = suite.verify(protocolVerificationRequest({ + requestId: "verification-credit-assertion-expiry-001", + credential: { credentialId: credential.credentialId }, + agentId: credential.agentId, + verificationLevel: "ASSOCIATED_CREDIT", + subjectCreditAssertionRef: credential.subjectCreditAssertionRef, + authorizationId: authorization.authorizationId, + purpose: credential.purpose, + requestedDataItems: ["associatedCreditValue", "mappingPolicy"], + requestedAt: currentTime, + antiReplay: { nonce: "verification-credit-assertion-expiry-nonce-001" }, + })); + assert.equal(response.result, "FAIL"); + assert.equal(response.reasonCode, "CREDIT_ASSERTION_INVALID"); +}); + +test("requires an ACTIVE subject-agent credential before authorization creation", () => { + const suite = strictSuite(); + assert.throws( + () => suite.createQueryAuthorization(signedAuthorization({ + authorizationId: "authorization-without-credential-001", + mode: "PER_REQUEST", + subjectId: "subject-demo-alice", + relyingPartyIds: ["relying-party-demo-shop"], + agentIds: ["agent-without-active-credential"], + purpose: "DEMO_TRUST_CHECK", + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "authorization-without-credential-request-001", + })), + /ACTIVE association credential is required/, + ); + + const application = suite.createAssociationRequest(demoAssociationRequest()); + const { credential } = suite.confirmAssociation(application.applicationId, { + confirmationMethod: "ATTESTED_CONFIRMATION", + }); + suite.changeCredentialStatusFromRequest( + credential.credentialId, + "REVOKED", + signedSubjectRequest({ + requestId: "authorization-precondition-revoke-001", + credentialId: credential.credentialId, + targetStatus: "REVOKED", + reasonCode: "SUBJECT_REQUEST", + requestedBy: credential.subjectId, + requestedAt: NOW, + antiReplay: { nonce: "authorization-precondition-revoke-nonce-001" }, + }), + ); + assert.throws( + () => suite.createQueryAuthorization(signedAuthorization({ + authorizationId: "authorization-after-revocation-001", + mode: "PER_REQUEST", + subjectId: credential.subjectId, + relyingPartyIds: ["relying-party-demo-shop"], + agentIds: [credential.agentId], + purpose: credential.purpose, + allowedDataItems: ["associatedCreditValue", "mappingPolicy"], + validFrom: "2026-01-01T00:00:00.000Z", + expiresAt: "2027-01-01T00:00:00.000Z", + boundRequestId: "authorization-after-revocation-request-001", + })), + /ACTIVE association credential is required/, + ); +}); diff --git a/code/samples/tsd-crd-reference/tsconfig.json b/code/samples/tsd-crd-reference/tsconfig.json new file mode 100644 index 0000000..e0e90d3 --- /dev/null +++ b/code/samples/tsd-crd-reference/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/code/schemas/README.md b/code/schemas/README.md index 09ddb42..3ee1be7 100644 --- a/code/schemas/README.md +++ b/code/schemas/README.md @@ -3,5 +3,7 @@ Machine-readable assets support implementations of ACT 2.1 without adding requirements that are absent from the human-readable specification. - [A402 JSON Schemas, fixtures, and tests](a402/README.md) +- [TSD-CRD Reference Profile v1](tsd-crd/README.md): JSON Schemas, + examples, OpenAPI local Sandbox binding, and fixed test vectors -Each artifact records its relationship to the corresponding specification and is validated by the repository test suite. +Each artifact records its relationship to the corresponding specification and is validated by the repository test suite. TSD-CRD `reference-v1` is an optional implementation profile, not a normative unified TSD wire contract. diff --git a/code/schemas/tsd-crd/README.md b/code/schemas/tsd-crd/README.md new file mode 100644 index 0000000..5b62333 --- /dev/null +++ b/code/schemas/tsd-crd/README.md @@ -0,0 +1,19 @@ +# TSD-CRD machine-readable artifacts + +> **Status: Implementation Artifact / Non-normative** + +This directory contains machine-readable implementation aids for the ACT 2.1 +TSD-CRD (Credit Association) subprotocol. The normative requirements remain in +[`docs/specification/trust-services.md`](../../../docs/specification/trust-services.md). + +- [`reference-v1/`](reference-v1/README.md): JSON Schema 2020-12, an OpenAPI + 3.1 local Sandbox binding, example messages, and fixed test vectors. + +`reference-v1` is an implementation profile version, not an ACT protocol +version and not an official unified TSD wire contract. Implementations only +claim this profile when they deliberately adopt its field names, Ed25519 +algorithm suite, signature projections, and HTTP binding. + +The artifacts were integrated from the standalone TSD-CRD source at commit +`fdf7006d97ae06645dce82400fac2dff964691f2`. Wire field casing and fixed signed +vectors were preserved during integration. diff --git a/code/schemas/tsd-crd/reference-v1/README.md b/code/schemas/tsd-crd/reference-v1/README.md new file mode 100644 index 0000000..ecfae79 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/README.md @@ -0,0 +1,60 @@ +# Reference Profile v1 + +> **状态:Implementation Artifact / Non-normative** + +本目录为 ACT 2.1 信任服务域的 TSD-CRD(信用关联)子篇提供首版机器可读参考格式。ACT 2.1 的规范性要求仍以[信任服务域正文](../../../../docs/specification/trust-services.md)为准;本 Profile 不会把正文未规定的字段名、算法或 HTTP 路径变成 ACT 要求。 + +`reference-v1` 是实现 Profile 版本,不是 ACT 协议版本。只有明确采用本目录字段、签名投影、Ed25519 算法套件和 HTTP 绑定的实现,才能声明支持该 Profile。 + +## 编码 + +- 报文使用 UTF-8 JSON。 +- 时间使用 UTC RFC 3339。 +- 签名算法使用 Ed25519。 +- 公钥使用 PEM SPKI,`format` 为 `pem-spki`。 +- 签名值使用无填充 Base64URL。 + +## 确定性 JSON + +签名前先执行[参考实现](../../../samples/tsd-crd-reference/src/core/canonical.ts)中的确定性序列化: + +1. 对象键按 JavaScript UTF-16 码元顺序升序排列。 +2. 不输出空白。 +3. 数组保持原顺序。 +4. 字符串和有限数字使用 ECMAScript `JSON.stringify` 表达。 +5. 拒绝负零、非有限数字、稀疏数组、循环引用、非普通对象、未配对代理项和非 JSON 类型。 + +这是 RFC 8785 兼容的受限 JSON 子集,不把未覆盖的输入类型纳入互操作范围。 + +## 签名投影 + +| 证明 | 签名前删除的字段 | +| --- | --- | +| DIRECT 主体内层签名 | `subjectPublicKey`、主体签名字段、签发方签名字段、`previousCredentialRef` | +| 凭证签发方外层签名 | 签发方签名字段、`statusQuery`、`previousCredentialRef` | +| 查询授权证明 | `authorizationProof`、运行时 `status` | +| 验证请求证明 | `requestProof` | +| 验证响应证明 | `responseProof` | +| 状态证明 | `statusProof` | + +DIRECT 待签名包由 `/v1/association-applications/{applicationId}/preparations` 返回。除 `associationApplicationId` 外,签名包还必须携带 `associationApplicationRequestedAt` 和 `associationApplicationAntiReplay`,把原申请时间及 nonce/幂等键直接纳入主体内层签名。服务端冻结草稿后不得静默改写再复用主体签名。 + +## 签名层数 + +- `ATTESTED_CONFIRMATION`:只有签发方外层签名。 +- `DIRECT_SIGNATURE`:主体内层签名和签发方外层签名。 + +Agent 公钥登记和 Agent 持钥证明不属于本 Profile。 + +## 信任解析 + +Schema 中的 `keyId` 只是标识。Reference Suite 必须从可信目录解析公钥并校验身份—公钥绑定;未配置解析器、无法解析或验签失败时失败关闭。Demo 使用的进程内测试目录不能作为生产信任依据。 + +## 目录 + +- `schemas/`:JSON Schema 2020-12。 +- `openapi/`:OpenAPI 3.1 HTTP 绑定。 +- `examples/`:结构示例,签名占位值不用于真实验签。 +- `test-vectors/`:固定正常/异常向量;其中已签名载荷和历史 `urn:acp:*` 示例值作为不可透明改写的兼容性测试数据保留,不代表 ACT 的命名空间要求。 + +本目录由独立 TSD-CRD 仓库提交 `fdf7006d97ae06645dce82400fac2dff964691f2` 迁入。迁入时保留 `camelCase` wire 字段与签名向量,避免静默破坏互操作性;如需更改这些内容,应发布新的 Profile 主版本。 diff --git a/code/schemas/tsd-crd/reference-v1/examples/agent-associated-credit-assertion.json b/code/schemas/tsd-crd/reference-v1/examples/agent-associated-credit-assertion.json new file mode 100644 index 0000000..61819ba --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/agent-associated-credit-assertion.json @@ -0,0 +1,31 @@ +{ + "assertionId": "agent-associated-credit-assertion-001", + "assertionVersion": "reference-v1", + "creditServiceId": "credit-service:example:provider", + "agentId": "agent:example:shopping-assistant", + "associationCredentialRef": "credential-attested-001", + "subjectCreditAssertionRef": "urn:example:credit-assertion:subject-credit-assertion-001", + "associatedCreditValue": { + "level": "A-ASSOCIATED", + "standing": "ELIGIBLE_FOR_REFERENCE" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "reference-level-mapping", + "version": "1.0" + }, + "purpose": "MERCHANT_RISK_REFERENCE", + "scope": [ + "merchant:example:demo-store", + "transaction:purchase" + ], + "issuedAt": "2026-08-12T08:10:00Z", + "validFrom": "2026-08-12T08:10:00Z", + "expiresAt": "2026-09-12T07:55:00Z", + "status": "ACTIVE", + "assertionProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "credit-service:example:provider#key-1", + "signatureValue": "AGENT_ASSOCIATED_CREDIT_ASSERTION_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/association-application-attested.json b/code/schemas/tsd-crd/reference-v1/examples/association-application-attested.json new file mode 100644 index 0000000..c936d8b --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/association-application-attested.json @@ -0,0 +1,24 @@ +{ + "applicationId": "application-attested-001", + "messageVersion": "reference-v1", + "subjectId": "subject:example:alice", + "agentId": "agent:example:shopping-assistant", + "associationRole": "OPERATOR", + "relationshipEvidenceRefs": [ + "urn:example:evidence:alice-operates-shopping-assistant" + ], + "confirmationMethod": "ATTESTED_CONFIRMATION", + "issuerId": "issuer:example:reference-service", + "purpose": "MERCHANT_RISK_REFERENCE", + "scope": [ + "merchant:example:demo-store", + "transaction:purchase" + ], + "authorizationMode": "PER_REQUEST", + "requestedAt": "2026-08-12T08:00:00Z", + "validFrom": "2026-08-12T08:00:00Z", + "expiresAt": "2027-08-12T08:00:00Z", + "antiReplay": { + "nonce": "association-nonce-attested-001" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/association-application-direct.json b/code/schemas/tsd-crd/reference-v1/examples/association-application-direct.json new file mode 100644 index 0000000..8f5a1be --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/association-application-direct.json @@ -0,0 +1,23 @@ +{ + "applicationId": "application-direct-001", + "messageVersion": "reference-v1", + "subjectId": "subject:example:company", + "agentId": "agent:example:service-assistant", + "associationRole": "CONTROLLER", + "relationshipEvidenceRefs": [ + "urn:example:evidence:company-controls-service-assistant" + ], + "confirmationMethod": "DIRECT_SIGNATURE", + "issuerId": "issuer:example:reference-service", + "purpose": "B2B_COUNTERPARTY_REVIEW", + "scope": [ + "business:example:procurement" + ], + "authorizationMode": "PLATFORM_DELEGATED", + "requestedAt": "2026-08-12T08:05:00Z", + "validFrom": "2026-08-12T08:05:00Z", + "expiresAt": "2027-08-12T08:05:00Z", + "antiReplay": { + "idempotencyKey": "association-direct-001" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/association-credential-attested.json b/code/schemas/tsd-crd/reference-v1/examples/association-credential-attested.json new file mode 100644 index 0000000..9fd1aeb --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/association-credential-attested.json @@ -0,0 +1,36 @@ +{ + "credentialId": "credential-attested-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-attested-001", + "agentId": "agent:example:shopping-assistant", + "subjectId": "subject:example:alice", + "associationRole": "OPERATOR", + "issuerId": "issuer:example:reference-service", + "relationshipEvidenceRef": "urn:example:evidence:alice-operates-shopping-assistant", + "confirmationMethod": "ATTESTED_CONFIRMATION", + "subjectCreditAssertionRef": "urn:example:credit-assertion:subject-credit-assertion-001", + "associatedCreditValue": { + "level": "A-ASSOCIATED", + "standing": "ELIGIBLE_FOR_REFERENCE" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "reference-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "The subject confirms the OPERATOR relationship for MERCHANT_RISK_REFERENCE within the declared scope.", + "purpose": "MERCHANT_RISK_REFERENCE", + "scope": [ + "merchant:example:demo-store", + "transaction:purchase" + ], + "issuedAt": "2026-08-12T08:10:00Z", + "validFrom": "2026-08-12T08:10:00Z", + "expiresAt": "2027-08-12T08:00:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-attested-001/status", + "method": "GET" + }, + "issuerSignature": "ATTESTED_ISSUER_OUTER_SIGNATURE_TEST_VALUE", + "issuerSignatureAlgorithm": "Ed25519" +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/association-credential-direct.json b/code/schemas/tsd-crd/reference-v1/examples/association-credential-direct.json new file mode 100644 index 0000000..52741bb --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/association-credential-direct.json @@ -0,0 +1,46 @@ +{ + "credentialId": "credential-direct-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-direct-001", + "agentId": "agent:example:service-assistant", + "subjectId": "subject:example:company", + "associationRole": "CONTROLLER", + "issuerId": "issuer:example:reference-service", + "relationshipEvidenceRef": "urn:example:evidence:company-controls-service-assistant", + "confirmationMethod": "DIRECT_SIGNATURE", + "subjectCreditAssertionRef": "urn:example:credit-assertion:subject-credit-assertion-company-001", + "associatedCreditValue": { + "level": "AA-ASSOCIATED", + "standing": "ELIGIBLE_FOR_REFERENCE" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "reference-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "The subject confirms the CONTROLLER relationship for B2B_COUNTERPARTY_REVIEW within the declared scope.", + "purpose": "B2B_COUNTERPARTY_REVIEW", + "scope": [ + "business:example:procurement" + ], + "associationApplicationRequestedAt": "2026-08-12T08:05:00Z", + "associationApplicationAntiReplay": { + "idempotencyKey": "association-direct-001" + }, + "subjectPublicKey": { + "keyId": "subject:example:company#key-1", + "format": "pem-spki", + "value": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAnVxhbm15980QOUSGDtbWucoUw6VxyAOv/fHlUhvohVM=\n-----END PUBLIC KEY-----\n" + }, + "subjectSignature": "DIRECT_SUBJECT_INNER_SIGNATURE_TEST_VALUE", + "subjectSignatureAlgorithm": "Ed25519", + "issuedAt": "2026-08-12T08:15:00Z", + "validFrom": "2026-08-12T08:15:00Z", + "expiresAt": "2027-08-12T08:05:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-direct-001/status", + "method": "GET" + }, + "issuerSignature": "DIRECT_ISSUER_OUTER_SIGNATURE_TEST_VALUE", + "issuerSignatureAlgorithm": "Ed25519" +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/credential-status-active.json b/code/schemas/tsd-crd/reference-v1/examples/credential-status-active.json new file mode 100644 index 0000000..9e2da8f --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/credential-status-active.json @@ -0,0 +1,13 @@ +{ + "credentialId": "credential-attested-001", + "status": "ACTIVE", + "statusVersion": 1, + "reasonCode": "ISSUED", + "effectiveAt": "2026-08-12T08:10:00Z", + "updatedAt": "2026-08-12T08:10:00Z", + "statusProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "issuer:example:reference-service#key-1", + "signatureValue": "CREDENTIAL_STATUS_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/credit-query-authorization-per-request.json b/code/schemas/tsd-crd/reference-v1/examples/credit-query-authorization-per-request.json new file mode 100644 index 0000000..1e97d5e --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/credit-query-authorization-per-request.json @@ -0,0 +1,32 @@ +{ + "authorizationId": "authorization-per-request-001", + "authorizationVersion": "reference-v1", + "mode": "PER_REQUEST", + "subjectId": "subject:example:alice", + "relyingPartyIds": [ + "relying-party:example:merchant" + ], + "agentIds": [ + "agent:example:shopping-assistant" + ], + "verificationLevel": "ASSOCIATED_CREDIT", + "purpose": "MERCHANT_RISK_REFERENCE", + "allowedDataItems": [ + "associatedCreditValue", + "mappingPolicy", + "subjectCreditAssertionValid" + ], + "validFrom": "2026-08-12T08:20:00Z", + "expiresAt": "2026-08-12T08:25:00Z", + "resultUseRestrictions": { + "mayStore": false, + "mayTransfer": false + }, + "status": "ACTIVE", + "boundRequestId": "verification-request-associated-credit-001", + "authorizationProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "subject:example:alice#key-1", + "signatureValue": "PER_REQUEST_AUTHORIZATION_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/credit-query-authorization-platform-delegated.json b/code/schemas/tsd-crd/reference-v1/examples/credit-query-authorization-platform-delegated.json new file mode 100644 index 0000000..1caf81a --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/credit-query-authorization-platform-delegated.json @@ -0,0 +1,37 @@ +{ + "authorizationId": "authorization-platform-delegated-001", + "authorizationVersion": "reference-v1", + "mode": "PLATFORM_DELEGATED", + "subjectId": "subject:example:company", + "relyingPartyIds": [ + "relying-party:example:procurement-platform" + ], + "platformDelegateId": "platform-delegate:example:procurement-platform", + "agentIds": [ + "agent:example:service-assistant" + ], + "verificationLevel": "ASSOCIATED_CREDIT", + "purpose": "B2B_COUNTERPARTY_REVIEW", + "allowedDataItems": [ + "associatedCreditValue", + "mappingPolicy", + "subjectCreditAssertionValid" + ], + "validFrom": "2026-08-12T08:20:00Z", + "expiresAt": "2026-09-12T08:20:00Z", + "frequencyLimit": { + "maxRequestsPerWindow": 10, + "windowDurationSeconds": 3600 + }, + "resultUseRestrictions": { + "mayStore": true, + "retentionSeconds": 86400, + "mayTransfer": false + }, + "status": "ACTIVE", + "authorizationProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "subject:example:company#key-1", + "signatureValue": "PLATFORM_AUTHORIZATION_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/status-change-suspend.json b/code/schemas/tsd-crd/reference-v1/examples/status-change-suspend.json new file mode 100644 index 0000000..7ece38f --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/status-change-suspend.json @@ -0,0 +1,16 @@ +{ + "requestId": "status-change-request-001", + "credentialId": "credential-attested-001", + "targetStatus": "SUSPENDED", + "reasonCode": "SUBJECT_REQUEST", + "requestedBy": "subject:example:alice", + "requestedAt": "2026-08-13T08:00:00Z", + "antiReplay": { + "nonce": "status-change-nonce-0001" + }, + "requestProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "subject:example:alice#key-1", + "signatureValue": "STATUS_CHANGE_REQUEST_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/subject-credit-assertion.json b/code/schemas/tsd-crd/reference-v1/examples/subject-credit-assertion.json new file mode 100644 index 0000000..922fd33 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/subject-credit-assertion.json @@ -0,0 +1,18 @@ +{ + "assertionId": "subject-credit-assertion-001", + "assertionVersion": "1.0", + "creditServiceId": "credit-service:example:provider", + "subjectId": "subject:example:alice", + "issuedAt": "2026-08-12T07:55:00Z", + "validFrom": "2026-08-12T07:55:00Z", + "expiresAt": "2026-09-12T07:55:00Z", + "creditPayload": { + "level": "A", + "standing": "GOOD" + }, + "assertionProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "credit-service:example:provider#key-1", + "signatureValue": "SUBJECT_CREDIT_ASSERTION_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/verification-request-associated-credit.json b/code/schemas/tsd-crd/reference-v1/examples/verification-request-associated-credit.json new file mode 100644 index 0000000..0eb1aaa --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/verification-request-associated-credit.json @@ -0,0 +1,32 @@ +{ + "requestId": "verification-request-associated-credit-001", + "messageVersion": "reference-v1", + "relyingPartyId": "relying-party:example:merchant", + "agentId": "agent:example:shopping-assistant", + "verificationLevel": "ASSOCIATED_CREDIT", + "subjectCreditAssertionRef": "urn:example:credit-assertion:subject-credit-assertion-001", + "authorizationId": "authorization-per-request-001", + "purpose": "MERCHANT_RISK_REFERENCE", + "businessContext": { + "orderId": "order-example-001", + "transactionType": "PURCHASE" + }, + "requestedDataItems": [ + "associatedCreditValue", + "mappingPolicy", + "subjectCreditAssertionValid" + ], + "credential": { + "credentialId": "credential-attested-001", + "uri": "https://issuer.example/credentials/credential-attested-001" + }, + "requestedAt": "2026-08-12T08:21:00Z", + "antiReplay": { + "nonce": "verification-nonce-associated-credit-001" + }, + "requestProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "relying-party:example:merchant#key-1", + "signatureValue": "ASSOCIATED_CREDIT_VERIFICATION_REQUEST_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/verification-request-credential.json b/code/schemas/tsd-crd/reference-v1/examples/verification-request-credential.json new file mode 100644 index 0000000..d537f2e --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/verification-request-credential.json @@ -0,0 +1,29 @@ +{ + "requestId": "verification-request-credential-001", + "messageVersion": "reference-v1", + "relyingPartyId": "relying-party:example:merchant", + "agentId": "agent:example:shopping-assistant", + "verificationLevel": "CREDENTIAL", + "purpose": "MERCHANT_RISK_REFERENCE", + "businessContext": { + "orderId": "order-example-001", + "transactionType": "PURCHASE" + }, + "requestedDataItems": [ + "credentialStatus", + "confirmationMethod" + ], + "credential": { + "credentialId": "credential-attested-001", + "uri": "https://issuer.example/credentials/credential-attested-001" + }, + "requestedAt": "2026-08-12T08:21:00Z", + "antiReplay": { + "nonce": "verification-nonce-credential-001" + }, + "requestProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "relying-party:example:merchant#key-1", + "signatureValue": "CREDENTIAL_VERIFICATION_REQUEST_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/verification-response-associated-credit-pass.json b/code/schemas/tsd-crd/reference-v1/examples/verification-response-associated-credit-pass.json new file mode 100644 index 0000000..cbb2d6b --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/verification-response-associated-credit-pass.json @@ -0,0 +1,35 @@ +{ + "requestId": "verification-request-associated-credit-001", + "verificationRecordId": "verification-record-associated-credit-001", + "agentId": "agent:example:shopping-assistant", + "completedLevel": "ASSOCIATED_CREDIT", + "result": "PASS", + "reasonCode": "VERIFIED", + "credentialStatus": "ACTIVE", + "creditSource": "ASSOCIATED_CREDIT", + "associatedCreditValue": { + "level": "A-ASSOCIATED", + "standing": "ELIGIBLE_FOR_REFERENCE" + }, + "subjectCreditAssertionValid": true, + "mappingPolicy": { + "id": "reference-level-mapping", + "version": "1.0" + }, + "scope": [ + "merchant:example:demo-store", + "transaction:purchase" + ], + "generatedAt": "2026-08-12T08:21:01Z", + "expiresAt": "2026-08-12T08:26:01Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-attested-001/status", + "method": "GET" + }, + "purposeLimited": true, + "responseProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "verifier:example:reference-service#key-1", + "signatureValue": "ASSOCIATED_CREDIT_VERIFICATION_RESPONSE_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/verification-response-credential-pass.json b/code/schemas/tsd-crd/reference-v1/examples/verification-response-credential-pass.json new file mode 100644 index 0000000..048f707 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/verification-response-credential-pass.json @@ -0,0 +1,25 @@ +{ + "requestId": "verification-request-credential-001", + "verificationRecordId": "verification-record-credential-001", + "agentId": "agent:example:shopping-assistant", + "completedLevel": "CREDENTIAL", + "result": "PASS", + "reasonCode": "VERIFIED", + "credentialStatus": "ACTIVE", + "scope": [ + "merchant:example:demo-store", + "transaction:purchase" + ], + "generatedAt": "2026-08-12T08:21:01Z", + "expiresAt": "2026-08-12T08:26:01Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-attested-001/status", + "method": "GET" + }, + "purposeLimited": true, + "responseProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "verifier:example:reference-service#key-1", + "signatureValue": "CREDENTIAL_VERIFICATION_RESPONSE_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/examples/verification-response-inconclusive.json b/code/schemas/tsd-crd/reference-v1/examples/verification-response-inconclusive.json new file mode 100644 index 0000000..62737bc --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/examples/verification-response-inconclusive.json @@ -0,0 +1,21 @@ +{ + "requestId": "verification-request-associated-credit-002", + "verificationRecordId": "verification-record-associated-credit-002", + "agentId": "agent:example:shopping-assistant", + "completedLevel": "CREDENTIAL", + "result": "INCONCLUSIVE", + "reasonCode": "CREDIT_ASSERTION_UNAVAILABLE", + "credentialStatus": "ACTIVE", + "scope": [ + "merchant:example:demo-store", + "transaction:purchase" + ], + "generatedAt": "2026-08-12T08:22:01Z", + "expiresAt": "2026-08-12T08:23:01Z", + "purposeLimited": true, + "responseProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "verifier:example:reference-service#key-1", + "signatureValue": "INCONCLUSIVE_VERIFICATION_RESPONSE_TEST_SIGNATURE" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/openapi/openapi.yaml b/code/schemas/tsd-crd/reference-v1/openapi/openapi.yaml new file mode 100644 index 0000000..812dc6b --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/openapi/openapi.yaml @@ -0,0 +1,361 @@ +openapi: 3.1.0 +info: + title: TSD-CRD Credit Association Reference API + version: 1.0.0 + description: >- + Non-normative local Sandbox binding for the ACT 2.1 TSD-CRD + reference-v1 implementation profile. ACT 2.1 does not require these HTTP + paths or wire names. The + protocol baseline supports ATTESTED_CONFIRMATION with an issuer outer + signature and DIRECT_SIGNATURE with a subject inner signature plus an + issuer outer signature. Agent key registration and Agent proof of + possession are optional extensions and are absent from this core API. +servers: + - url: http://127.0.0.1:8787 + description: Local reference sandbox +tags: + - name: Association + - name: Credential + - name: Authorization + - name: Verification +paths: + /v1/association-applications: + post: + tags: [Association] + operationId: createAssociationApplication + summary: Create a pending credit association application + requestBody: + required: true + content: + application/json: + schema: + $ref: ../schemas/association-application.schema.json + examples: + attested: + externalValue: ../examples/association-application-attested.json + responses: + "201": + description: Application accepted + content: + application/json: + schema: + $ref: ../schemas/association-application.schema.json + "400": + $ref: "#/components/responses/ProtocolError" + /v1/association-applications/{applicationId}/confirmations: + parameters: + - $ref: "#/components/parameters/ApplicationId" + post: + tags: [Association] + operationId: confirmAssociationApplication + summary: Confirm, map and issue a credit association credential + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/AttestedConfirmation" + - $ref: "#/components/schemas/DirectConfirmation" + responses: + "201": + description: Credential issued and active + content: + application/json: + schema: + $ref: ../schemas/association-credential.schema.json + examples: + attested: + externalValue: ../examples/association-credential-attested.json + direct: + externalValue: ../examples/association-credential-direct.json + "400": + $ref: "#/components/responses/ProtocolError" + "404": + $ref: "#/components/responses/ProtocolError" + /v1/association-applications/{applicationId}/preparations: + parameters: + - $ref: "#/components/parameters/ApplicationId" + post: + tags: [Association] + operationId: prepareDirectAssociationConfirmation + summary: Prepare the deterministic DIRECT_SIGNATURE payload + description: >- + Returns the frozen credential draft and signing payload. The subject + signs the returned signingPayload locally; its private key is never + submitted to the Sandbox. + responses: + "200": + description: DIRECT_SIGNATURE payload prepared + content: + application/json: + schema: + $ref: "#/components/schemas/DirectPreparation" + "400": + $ref: "#/components/responses/ProtocolError" + "404": + $ref: "#/components/responses/ProtocolError" + /v1/association-credentials/{credentialId}: + parameters: + - $ref: "#/components/parameters/CredentialId" + get: + tags: [Credential] + operationId: getAssociationCredential + summary: Get a credit association credential + responses: + "200": + description: Credential + content: + application/json: + schema: + $ref: ../schemas/association-credential.schema.json + "404": + $ref: "#/components/responses/ProtocolError" + /v1/association-credentials/{credentialId}/status: + parameters: + - $ref: "#/components/parameters/CredentialId" + get: + tags: [Credential] + operationId: getAssociationCredentialStatus + summary: Get the current credential status + responses: + "200": + description: Current status + content: + application/json: + schema: + $ref: ../schemas/credential-status.schema.json + "404": + $ref: "#/components/responses/ProtocolError" + /v1/association-credentials/{credentialId}/suspensions: + parameters: + - $ref: "#/components/parameters/CredentialId" + post: + tags: [Credential] + operationId: suspendAssociationCredential + summary: Suspend an active credential + requestBody: + $ref: "#/components/requestBodies/StatusChange" + responses: + "200": + $ref: "#/components/responses/CredentialStatus" + "400": + $ref: "#/components/responses/ProtocolError" + "404": + $ref: "#/components/responses/ProtocolError" + /v1/association-credentials/{credentialId}/resumptions: + parameters: + - $ref: "#/components/parameters/CredentialId" + post: + tags: [Credential] + operationId: resumeAssociationCredential + summary: Resume a suspended credential + requestBody: + $ref: "#/components/requestBodies/StatusChange" + responses: + "200": + $ref: "#/components/responses/CredentialStatus" + "400": + $ref: "#/components/responses/ProtocolError" + "404": + $ref: "#/components/responses/ProtocolError" + /v1/association-credentials/{credentialId}/revocations: + parameters: + - $ref: "#/components/parameters/CredentialId" + post: + tags: [Credential] + operationId: revokeAssociationCredential + summary: Irreversibly revoke a credential + requestBody: + $ref: "#/components/requestBodies/StatusChange" + responses: + "200": + $ref: "#/components/responses/CredentialStatus" + "400": + $ref: "#/components/responses/ProtocolError" + "404": + $ref: "#/components/responses/ProtocolError" + /v1/credit-query-authorizations: + post: + tags: [Authorization] + operationId: createCreditQueryAuthorization + summary: Create per-request or platform-delegated query authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: ../schemas/credit-query-authorization.schema.json + examples: + perRequest: + externalValue: ../examples/credit-query-authorization-per-request.json + platformDelegated: + externalValue: ../examples/credit-query-authorization-platform-delegated.json + responses: + "201": + description: Authorization created + content: + application/json: + schema: + $ref: ../schemas/credit-query-authorization.schema.json + "400": + $ref: "#/components/responses/ProtocolError" + /v1/credit-query-authorizations/{authorizationId}/revocations: + parameters: + - $ref: "#/components/parameters/AuthorizationId" + post: + tags: [Authorization] + operationId: revokeCreditQueryAuthorization + summary: Revoke a credit query authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: ../schemas/authorization-revocation-request.schema.json + responses: + "200": + description: Revoked authorization + content: + application/json: + schema: + $ref: ../schemas/credit-query-authorization.schema.json + "400": + $ref: "#/components/responses/ProtocolError" + "404": + $ref: "#/components/responses/ProtocolError" + /v1/verifications: + post: + tags: [Verification] + operationId: verifyAssociatedCredit + summary: Verify a credential or its associated credit information + requestBody: + required: true + content: + application/json: + schema: + $ref: ../schemas/verification-request.schema.json + examples: + credential: + externalValue: ../examples/verification-request-credential.json + associatedCredit: + externalValue: ../examples/verification-request-associated-credit.json + responses: + "200": + description: Signed verification result + content: + application/json: + schema: + $ref: ../schemas/verification-response.schema.json + examples: + credential: + externalValue: ../examples/verification-response-credential-pass.json + associatedCredit: + externalValue: ../examples/verification-response-associated-credit-pass.json + "400": + $ref: "#/components/responses/ProtocolError" +components: + parameters: + ApplicationId: + name: applicationId + in: path + required: true + schema: + type: string + minLength: 1 + CredentialId: + name: credentialId + in: path + required: true + schema: + type: string + minLength: 1 + AuthorizationId: + name: authorizationId + in: path + required: true + schema: + type: string + minLength: 1 + requestBodies: + StatusChange: + required: true + content: + application/json: + schema: + $ref: ../schemas/status-change-request.schema.json + responses: + CredentialStatus: + description: Updated credential status + content: + application/json: + schema: + $ref: ../schemas/credential-status.schema.json + ProtocolError: + description: Protocol or input error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + DirectPreparation: + type: object + additionalProperties: false + required: [applicationId, credential, signingPayload] + properties: + applicationId: + type: string + minLength: 1 + credential: + $ref: ../schemas/direct-signing-payload.schema.json + signingPayload: + $ref: ../schemas/direct-signing-payload.schema.json + AttestedConfirmation: + type: object + additionalProperties: false + required: + - confirmationMethod + properties: + confirmationMethod: + const: ATTESTED_CONFIRMATION + attestationRef: + type: string + format: uri-reference + description: >- + Produces a credential with the issuer outer signature only. Subject + public-key and subject-signature fields are absent. + DirectConfirmation: + type: object + additionalProperties: false + required: + - confirmationMethod + - subjectPublicKey + - subjectSignature + - subjectSignatureAlgorithm + properties: + confirmationMethod: + const: DIRECT_SIGNATURE + subjectPublicKey: + $ref: ../schemas/common.schema.json#/$defs/publicKey + subjectSignature: + type: string + minLength: 1 + subjectSignatureAlgorithm: + type: string + minLength: 1 + ErrorResponse: + type: object + additionalProperties: false + required: [error] + properties: + error: + type: object + additionalProperties: false + required: [code, message] + properties: + code: + type: string + minLength: 1 + message: + type: string + minLength: 1 diff --git a/code/schemas/tsd-crd/reference-v1/schemas/agent-associated-credit-assertion.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/agent-associated-credit-assertion.schema.json new file mode 100644 index 0000000..d2b51a3 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/agent-associated-credit-assertion.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent-associated-credit-assertion.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Agent Associated Credit Assertion", + "type": "object", + "additionalProperties": false, + "required": [ + "assertionId", + "assertionVersion", + "creditServiceId", + "agentId", + "associationCredentialRef", + "subjectCreditAssertionRef", + "associatedCreditValue", + "creditSource", + "mappingPolicy", + "purpose", + "scope", + "issuedAt", + "validFrom", + "status" + ], + "properties": { + "assertionId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "assertionVersion": { + "type": "string", + "minLength": 1 + }, + "creditServiceId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "agentId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "associationCredentialRef": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "subjectCreditAssertionRef": { + "$ref": "common.schema.json#/$defs/uriReference" + }, + "associatedCreditValue": { + "$ref": "common.schema.json#/$defs/associatedCreditValue" + }, + "creditSource": { + "type": "string", + "const": "ASSOCIATED_CREDIT" + }, + "mappingPolicy": { + "$ref": "common.schema.json#/$defs/mappingPolicy" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "scope": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "issuedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "validFrom": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "SUSPENDED", + "REVOKED", + "EXPIRED" + ] + }, + "assertionProof": { + "$ref": "common.schema.json#/$defs/proof" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/association-application.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/association-application.schema.json new file mode 100644 index 0000000..72e43f1 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/association-application.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "association-application.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Credit Association Application", + "type": "object", + "additionalProperties": false, + "required": [ + "applicationId", + "messageVersion", + "subjectId", + "agentId", + "associationRole", + "relationshipEvidenceRefs", + "confirmationMethod", + "issuerId", + "purpose", + "scope", + "authorizationMode", + "requestedAt", + "validFrom", + "antiReplay" + ], + "properties": { + "applicationId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "messageVersion": { + "type": "string", + "const": "reference-v1" + }, + "subjectId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "agentId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "associationRole": { + "type": "string", + "minLength": 1, + "description": "Examples include DEVELOPER, DEPLOYER, OPERATOR, CONTROLLER and RESPONSIBLE_PARTY." + }, + "relationshipEvidenceRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "common.schema.json#/$defs/uriReference" + } + }, + "confirmationMethod": { + "$ref": "common.schema.json#/$defs/confirmationMethod" + }, + "issuerId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "scope": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "authorizationMode": { + "type": "string", + "enum": [ + "PER_REQUEST", + "PLATFORM_DELEGATED" + ] + }, + "requestedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "validFrom": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "antiReplay": { + "$ref": "common.schema.json#/$defs/antiReplay" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/association-credential.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/association-credential.schema.json new file mode 100644 index 0000000..7875c3c --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/association-credential.schema.json @@ -0,0 +1,185 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "association-credential.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Credit Association Credential", + "description": "The core credential contains no Agent public key or Agent proof-of-possession field. Such mechanisms belong to optional extensions.", + "type": "object", + "additionalProperties": false, + "required": [ + "credentialId", + "credentialVersion", + "associationApplicationId", + "agentId", + "subjectId", + "associationRole", + "issuerId", + "relationshipEvidenceRef", + "confirmationMethod", + "subjectCreditAssertionRef", + "associatedCreditValue", + "creditSource", + "mappingPolicy", + "confirmationStatement", + "purpose", + "scope", + "issuedAt", + "validFrom", + "statusQuery", + "issuerSignature", + "issuerSignatureAlgorithm" + ], + "properties": { + "credentialId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "credentialVersion": { + "type": "string", + "const": "reference-v1" + }, + "associationApplicationId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "agentId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "subjectId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "associationRole": { + "type": "string", + "minLength": 1 + }, + "issuerId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "relationshipEvidenceRef": { + "$ref": "common.schema.json#/$defs/uriReference" + }, + "confirmationMethod": { + "$ref": "common.schema.json#/$defs/confirmationMethod" + }, + "subjectCreditAssertionRef": { + "$ref": "common.schema.json#/$defs/uriReference" + }, + "associatedCreditValue": { + "$ref": "common.schema.json#/$defs/associatedCreditValue" + }, + "creditSource": { + "type": "string", + "const": "ASSOCIATED_CREDIT" + }, + "mappingPolicy": { + "$ref": "common.schema.json#/$defs/mappingPolicy" + }, + "confirmationStatement": { + "type": "string", + "minLength": 1 + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "scope": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "associationApplicationRequestedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "associationApplicationAntiReplay": { + "$ref": "common.schema.json#/$defs/antiReplay" + }, + "subjectPublicKey": { + "$ref": "common.schema.json#/$defs/publicKey" + }, + "subjectSignature": { + "type": "string", + "minLength": 1, + "contentEncoding": "base64url" + }, + "subjectSignatureAlgorithm": { + "type": "string", + "const": "Ed25519" + }, + "issuedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "validFrom": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "statusQuery": { + "$ref": "common.schema.json#/$defs/statusQuery" + }, + "previousCredentialRef": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "issuerSignature": { + "type": "string", + "minLength": 1, + "contentEncoding": "base64url" + }, + "issuerSignatureAlgorithm": { + "type": "string", + "const": "Ed25519" + } + }, + "allOf": [ + { + "if": { + "properties": { + "confirmationMethod": { + "const": "DIRECT_SIGNATURE" + } + }, + "required": [ + "confirmationMethod" + ] + }, + "then": { + "required": [ + "associationApplicationRequestedAt", + "associationApplicationAntiReplay", + "subjectPublicKey", + "subjectSignature", + "subjectSignatureAlgorithm" + ] + } + }, + { + "if": { + "properties": { + "confirmationMethod": { + "const": "ATTESTED_CONFIRMATION" + } + }, + "required": [ + "confirmationMethod" + ] + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "subjectPublicKey" + ] + }, + { + "required": [ + "subjectSignature" + ] + }, + { + "required": [ + "subjectSignatureAlgorithm" + ] + } + ] + } + } + } + ] +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/authorization-revocation-request.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/authorization-revocation-request.schema.json new file mode 100644 index 0000000..4b3144b --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/authorization-revocation-request.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "authorization-revocation-request.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Credit Query Authorization Revocation Request", + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "authorizationId", + "subjectId", + "reasonCode", + "requestedAt", + "antiReplay", + "requestProof" + ], + "properties": { + "requestId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "authorizationId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "subjectId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "reasonCode": { + "type": "string", + "minLength": 1 + }, + "requestedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "antiReplay": { + "$ref": "common.schema.json#/$defs/antiReplay" + }, + "requestProof": { + "$ref": "common.schema.json#/$defs/proof" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/common.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/common.schema.json new file mode 100644 index 0000000..3d5c0cd --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/common.schema.json @@ -0,0 +1,258 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "common.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "TSD-CRD Reference Profile v1 Common Types", + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "uriReference": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "stringSet": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "confirmationMethod": { + "type": "string", + "enum": [ + "DIRECT_SIGNATURE", + "ATTESTED_CONFIRMATION" + ] + }, + "credentialStatus": { + "type": "string", + "enum": [ + "PENDING", + "ACTIVE", + "SUSPENDED", + "REVOKED", + "EXPIRED" + ] + }, + "verificationLevel": { + "type": "string", + "enum": [ + "CREDENTIAL", + "ASSOCIATED_CREDIT" + ] + }, + "verificationResult": { + "type": "string", + "enum": [ + "PASS", + "FAIL", + "INCONCLUSIVE", + "REVIEW_REQUIRED" + ] + }, + "reasonCode": { + "type": "string", + "enum": [ + "VERIFIED", + "INVALID_REQUEST", + "REQUEST_PROOF_INVALID", + "REQUEST_EXPIRED", + "REPLAY_DETECTED", + "AUTHORIZATION_REQUIRED", + "AUTHORIZATION_EXPIRED", + "AUTHORIZATION_REVOKED", + "AUTHORIZATION_SCOPE_MISMATCH", + "AGENT_NOT_REGISTERED", + "ASSOCIATION_CREDENTIAL_NOT_FOUND", + "ASSOCIATION_CREDENTIAL_NOT_ACTIVE", + "ASSOCIATION_PROOF_INVALID", + "CREDIT_ASSERTION_UNAVAILABLE", + "CREDIT_ASSERTION_INVALID", + "MAPPING_POLICY_UNSUPPORTED", + "INCONCLUSIVE", + "REVIEW_REQUIRED" + ] + }, + "mappingPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "version": { + "type": "string", + "minLength": 1 + } + } + }, + "associatedCreditValue": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "minProperties": 1 + }, + { + "type": "array", + "minItems": 1 + } + ] + }, + "statusQuery": { + "type": "object", + "additionalProperties": false, + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "uri-reference" + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST" + ], + "default": "GET" + } + } + }, + "publicKey": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyId", + "format", + "value" + ], + "properties": { + "keyId": { + "$ref": "#/$defs/identifier" + }, + "format": { + "type": "string", + "const": "pem-spki", + "description": "PEM-encoded SubjectPublicKeyInfo." + }, + "value": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "minProperties": 1 + } + ] + } + } + }, + "proof": { + "type": "object", + "additionalProperties": false, + "required": [ + "signatureAlgorithm", + "signatureValue" + ], + "properties": { + "signatureAlgorithm": { + "type": "string", + "const": "Ed25519" + }, + "keyId": { + "$ref": "#/$defs/identifier" + }, + "signatureValue": { + "type": "string", + "minLength": 1, + "contentEncoding": "base64url" + } + } + }, + "antiReplay": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "nonce": { + "type": "string", + "minLength": 16, + "maxLength": 256 + }, + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 256 + } + }, + "anyOf": [ + { + "required": [ + "nonce" + ] + }, + { + "required": [ + "idempotencyKey" + ] + } + ] + }, + "businessContext": { + "type": "object", + "minProperties": 1, + "maxProperties": 32, + "additionalProperties": { + "type": [ + "string", + "number", + "integer", + "boolean" + ] + } + }, + "credentialReference": { + "type": "object", + "additionalProperties": false, + "required": [ + "credentialId" + ], + "properties": { + "credentialId": { + "$ref": "#/$defs/identifier" + }, + "uri": { + "type": "string", + "format": "uri" + } + } + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/credential-status.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/credential-status.schema.json new file mode 100644 index 0000000..bf7c6c4 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/credential-status.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "credential-status.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Credit Association Credential Status", + "type": "object", + "additionalProperties": false, + "required": [ + "credentialId", + "status", + "statusVersion", + "effectiveAt", + "updatedAt", + "statusProof" + ], + "properties": { + "credentialId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "status": { + "$ref": "common.schema.json#/$defs/credentialStatus" + }, + "statusVersion": { + "type": "integer", + "minimum": 1 + }, + "reasonCode": { + "type": "string", + "minLength": 1 + }, + "effectiveAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "updatedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "statusProof": { + "$ref": "common.schema.json#/$defs/proof" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/credit-query-authorization.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/credit-query-authorization.schema.json new file mode 100644 index 0000000..3a065b3 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/credit-query-authorization.schema.json @@ -0,0 +1,196 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "credit-query-authorization.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Credit Query Authorization", + "type": "object", + "additionalProperties": false, + "required": [ + "authorizationId", + "authorizationVersion", + "mode", + "subjectId", + "relyingPartyIds", + "agentIds", + "verificationLevel", + "purpose", + "allowedDataItems", + "validFrom", + "expiresAt", + "resultUseRestrictions", + "status", + "authorizationProof" + ], + "properties": { + "authorizationId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "authorizationVersion": { + "type": "string", + "const": "reference-v1" + }, + "mode": { + "type": "string", + "enum": [ + "PER_REQUEST", + "PLATFORM_DELEGATED" + ] + }, + "subjectId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "relyingPartyIds": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "platformDelegateId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "agentIds": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "verificationLevel": { + "type": "string", + "const": "ASSOCIATED_CREDIT" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "allowedDataItems": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "validFrom": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "frequencyLimit": { + "type": "object", + "additionalProperties": false, + "required": [ + "maxRequestsPerWindow", + "windowDurationSeconds" + ], + "properties": { + "maxRequestsPerWindow": { + "type": "integer", + "minimum": 1 + }, + "windowDurationSeconds": { + "type": "integer", + "minimum": 1 + } + } + }, + "resultUseRestrictions": { + "type": "object", + "additionalProperties": false, + "required": [ + "mayStore", + "mayTransfer" + ], + "properties": { + "mayStore": { + "type": "boolean" + }, + "retentionSeconds": { + "type": "integer", + "minimum": 1 + }, + "mayTransfer": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "mayStore": { + "const": true + } + }, + "required": [ + "mayStore" + ] + }, + "then": { + "required": [ + "retentionSeconds" + ] + } + } + ] + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "REVOKED", + "EXPIRED" + ] + }, + "boundRequestId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "authorizationProof": { + "$ref": "common.schema.json#/$defs/proof" + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "PER_REQUEST" + } + }, + "required": [ + "mode" + ] + }, + "then": { + "required": [ + "boundRequestId" + ], + "not": { + "anyOf": [ + { + "required": [ + "platformDelegateId" + ] + }, + { + "required": [ + "frequencyLimit" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "mode": { + "const": "PLATFORM_DELEGATED" + } + }, + "required": [ + "mode" + ] + }, + "then": { + "required": [ + "platformDelegateId", + "frequencyLimit" + ], + "not": { + "required": [ + "boundRequestId" + ] + } + } + } + ] +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/direct-signing-payload.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/direct-signing-payload.schema.json new file mode 100644 index 0000000..4eea1e2 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/direct-signing-payload.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "direct-signing-payload.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "DIRECT_SIGNATURE Signing Payload", + "description": "Frozen credential draft signed by the associated subject before issuer signing.", + "type": "object", + "additionalProperties": false, + "required": [ + "credentialId", + "credentialVersion", + "associationApplicationId", + "agentId", + "subjectId", + "associationRole", + "issuerId", + "relationshipEvidenceRef", + "confirmationMethod", + "subjectCreditAssertionRef", + "associatedCreditValue", + "creditSource", + "mappingPolicy", + "confirmationStatement", + "purpose", + "scope", + "associationApplicationRequestedAt", + "associationApplicationAntiReplay", + "issuedAt", + "validFrom", + "statusQuery" + ], + "properties": { + "credentialId": { + "$ref": "association-credential.schema.json#/properties/credentialId" + }, + "credentialVersion": { + "$ref": "association-credential.schema.json#/properties/credentialVersion" + }, + "associationApplicationId": { + "$ref": "association-credential.schema.json#/properties/associationApplicationId" + }, + "agentId": { + "$ref": "association-credential.schema.json#/properties/agentId" + }, + "subjectId": { + "$ref": "association-credential.schema.json#/properties/subjectId" + }, + "associationRole": { + "$ref": "association-credential.schema.json#/properties/associationRole" + }, + "issuerId": { + "$ref": "association-credential.schema.json#/properties/issuerId" + }, + "relationshipEvidenceRef": { + "$ref": "association-credential.schema.json#/properties/relationshipEvidenceRef" + }, + "confirmationMethod": { + "const": "DIRECT_SIGNATURE" + }, + "subjectCreditAssertionRef": { + "$ref": "association-credential.schema.json#/properties/subjectCreditAssertionRef" + }, + "associatedCreditValue": { + "$ref": "association-credential.schema.json#/properties/associatedCreditValue" + }, + "creditSource": { + "const": "ASSOCIATED_CREDIT" + }, + "mappingPolicy": { + "$ref": "association-credential.schema.json#/properties/mappingPolicy" + }, + "confirmationStatement": { + "$ref": "association-credential.schema.json#/properties/confirmationStatement" + }, + "purpose": { + "$ref": "association-credential.schema.json#/properties/purpose" + }, + "scope": { + "$ref": "association-credential.schema.json#/properties/scope" + }, + "associationApplicationRequestedAt": { + "$ref": "association-credential.schema.json#/properties/associationApplicationRequestedAt" + }, + "associationApplicationAntiReplay": { + "$ref": "association-credential.schema.json#/properties/associationApplicationAntiReplay" + }, + "issuedAt": { + "$ref": "association-credential.schema.json#/properties/issuedAt" + }, + "validFrom": { + "$ref": "association-credential.schema.json#/properties/validFrom" + }, + "expiresAt": { + "$ref": "association-credential.schema.json#/properties/expiresAt" + }, + "statusQuery": { + "$ref": "association-credential.schema.json#/properties/statusQuery" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/status-change-request.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/status-change-request.schema.json new file mode 100644 index 0000000..1c77809 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/status-change-request.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "status-change-request.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Credential Status Change Request", + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "credentialId", + "targetStatus", + "reasonCode", + "requestedBy", + "requestedAt", + "antiReplay", + "requestProof" + ], + "properties": { + "requestId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "credentialId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "targetStatus": { + "type": "string", + "enum": [ + "ACTIVE", + "SUSPENDED", + "REVOKED" + ] + }, + "reasonCode": { + "type": "string", + "minLength": 1 + }, + "requestedBy": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "requestedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "antiReplay": { + "$ref": "common.schema.json#/$defs/antiReplay" + }, + "requestProof": { + "$ref": "common.schema.json#/$defs/proof" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/subject-credit-assertion.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/subject-credit-assertion.schema.json new file mode 100644 index 0000000..4974254 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/subject-credit-assertion.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "subject-credit-assertion.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Association Subject Credit Assertion", + "type": "object", + "additionalProperties": false, + "required": [ + "assertionId", + "assertionVersion", + "creditServiceId", + "subjectId", + "issuedAt", + "validFrom", + "creditPayload" + ], + "properties": { + "assertionId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "assertionVersion": { + "type": "string", + "minLength": 1 + }, + "creditServiceId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "subjectId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "issuedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "validFrom": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "creditPayload": { + "type": "object", + "minProperties": 1, + "description": "Provider-defined credit content. Access remains subject to data protection and authorization rules." + }, + "assertionProof": { + "$ref": "common.schema.json#/$defs/proof" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/verification-request.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/verification-request.schema.json new file mode 100644 index 0000000..a650956 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/verification-request.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "verification-request.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Associated Credit Verification Request", + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "messageVersion", + "relyingPartyId", + "agentId", + "verificationLevel", + "purpose", + "businessContext", + "requestedDataItems", + "credential", + "requestedAt", + "antiReplay", + "requestProof" + ], + "properties": { + "requestId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "messageVersion": { + "type": "string", + "const": "reference-v1" + }, + "relyingPartyId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "agentId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "verificationLevel": { + "$ref": "common.schema.json#/$defs/verificationLevel" + }, + "subjectCreditAssertionRef": { + "$ref": "common.schema.json#/$defs/uriReference" + }, + "authorizationId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "platformDelegateId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "businessContext": { + "$ref": "common.schema.json#/$defs/businessContext" + }, + "requestedDataItems": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "credential": { + "oneOf": [ + { + "$ref": "association-credential.schema.json" + }, + { + "$ref": "common.schema.json#/$defs/credentialReference" + } + ] + }, + "requestedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "antiReplay": { + "$ref": "common.schema.json#/$defs/antiReplay" + }, + "requestProof": { + "$ref": "common.schema.json#/$defs/proof" + } + }, + "allOf": [ + { + "if": { + "properties": { + "verificationLevel": { + "const": "ASSOCIATED_CREDIT" + } + }, + "required": [ + "verificationLevel" + ] + }, + "then": { + "required": [ + "subjectCreditAssertionRef", + "authorizationId" + ] + } + } + ] +} diff --git a/code/schemas/tsd-crd/reference-v1/schemas/verification-response.schema.json b/code/schemas/tsd-crd/reference-v1/schemas/verification-response.schema.json new file mode 100644 index 0000000..095e55a --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/schemas/verification-response.schema.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "verification-response.schema.json", + "$comment": "Implementation Artifact / Non-normative. ACT 2.1 human-readable specification is authoritative.", + "title": "Associated Credit Verification Response", + "type": "object", + "additionalProperties": false, + "required": [ + "requestId", + "verificationRecordId", + "agentId", + "completedLevel", + "result", + "reasonCode", + "credentialStatus", + "scope", + "generatedAt", + "expiresAt", + "purposeLimited", + "responseProof" + ], + "properties": { + "requestId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "verificationRecordId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "agentId": { + "$ref": "common.schema.json#/$defs/identifier" + }, + "completedLevel": { + "$ref": "common.schema.json#/$defs/verificationLevel" + }, + "result": { + "$ref": "common.schema.json#/$defs/verificationResult" + }, + "reasonCode": { + "$ref": "common.schema.json#/$defs/reasonCode" + }, + "credentialStatus": { + "$ref": "common.schema.json#/$defs/credentialStatus" + }, + "creditSource": { + "type": "string", + "const": "ASSOCIATED_CREDIT" + }, + "associatedCreditValue": { + "$ref": "common.schema.json#/$defs/associatedCreditValue" + }, + "subjectCreditAssertionValid": { + "type": "boolean" + }, + "mappingPolicy": { + "$ref": "common.schema.json#/$defs/mappingPolicy" + }, + "scope": { + "$ref": "common.schema.json#/$defs/stringSet" + }, + "generatedAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "common.schema.json#/$defs/timestamp" + }, + "statusQuery": { + "$ref": "common.schema.json#/$defs/statusQuery" + }, + "purposeLimited": { + "type": "boolean", + "const": true + }, + "responseProof": { + "$ref": "common.schema.json#/$defs/proof" + } + }, + "allOf": [ + { + "if": { + "properties": { + "completedLevel": { + "const": "CREDENTIAL" + } + }, + "required": [ + "completedLevel" + ] + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "creditSource" + ] + }, + { + "required": [ + "associatedCreditValue" + ] + }, + { + "required": [ + "subjectCreditAssertionValid" + ] + }, + { + "required": [ + "mappingPolicy" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "completedLevel": { + "const": "ASSOCIATED_CREDIT" + }, + "result": { + "const": "PASS" + } + }, + "required": [ + "completedLevel", + "result" + ] + }, + "then": { + "required": [ + "creditSource", + "associatedCreditValue", + "subjectCreditAssertionValid", + "mappingPolicy" + ], + "properties": { + "subjectCreditAssertionValid": { + "const": true + } + } + } + }, + { + "if": { + "properties": { + "result": { + "const": "PASS" + } + }, + "required": [ + "result" + ] + }, + "then": { + "properties": { + "reasonCode": { + "const": "VERIFIED" + }, + "credentialStatus": { + "const": "ACTIVE" + } + } + } + } + ] +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-agent-proof-in-core.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-agent-proof-in-core.json new file mode 100644 index 0000000..c2e05bc --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-agent-proof-in-core.json @@ -0,0 +1,39 @@ +{ + "id": "association-credential-agent-proof-in-core", + "description": "Agent key registration and Agent proof-of-possession are outside the protocol baseline and rejected as undeclared core fields.", + "schema": "association-credential.schema.json", + "expectedValid": false, + "instance": { + "credentialId": "credential-invalid-agent-pop-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-invalid-agent-pop-001", + "agentId": "agent:vector:shopping-assistant", + "subjectId": "subject:vector:alice", + "issuerId": "issuer:vector:reference-service", + "relationshipEvidenceRef": "urn:vector:evidence:alice-operates-agent", + "confirmationMethod": "ATTESTED_CONFIRMATION", + "subjectCreditAssertionRef": "urn:vector:credit-assertion:alice-001", + "associatedCreditValue": { + "level": "A-ASSOCIATED" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "vector-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "Attested confirmation.", + "purpose": "MERCHANT_RISK_REFERENCE", + "scope": [ + "merchant:vector:store" + ], + "agentPublicKey": "AGENT_PUBLIC_KEY_EXTENSION_FIELD", + "agentProofOfPossession": "AGENT_POP_EXTENSION_FIELD", + "issuedAt": "2026-08-12T10:10:00Z", + "validFrom": "2026-08-12T10:10:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-invalid-agent-pop-001/status" + }, + "issuerSignature": "OUTER_SIGNATURE", + "issuerSignatureAlgorithm": "Ed25519" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-attested-with-subject-signature.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-attested-with-subject-signature.json new file mode 100644 index 0000000..0ee03b7 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-attested-with-subject-signature.json @@ -0,0 +1,44 @@ +{ + "id": "association-credential-attested-with-subject-signature", + "description": "ATTESTED_CONFIRMATION must not carry DIRECT_SIGNATURE subject key or inner-signature fields.", + "schema": "association-credential.schema.json", + "expectedValid": false, + "instance": { + "credentialId": "credential-invalid-attested-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-invalid-attested-001", + "agentId": "agent:vector:shopping-assistant", + "subjectId": "subject:vector:alice", + "issuerId": "issuer:vector:reference-service", + "relationshipEvidenceRef": "urn:vector:evidence:alice-operates-agent", + "confirmationMethod": "ATTESTED_CONFIRMATION", + "subjectCreditAssertionRef": "urn:vector:credit-assertion:alice-001", + "associatedCreditValue": { + "level": "A-ASSOCIATED" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "vector-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "Attested confirmation.", + "purpose": "MERCHANT_RISK_REFERENCE", + "scope": [ + "merchant:vector:store" + ], + "subjectPublicKey": { + "keyId": "subject:vector:alice#key-1", + "format": "pem-spki", + "value": "PUBLIC_KEY_MUST_BE_ABSENT" + }, + "subjectSignature": "INNER_SIGNATURE_MUST_BE_ABSENT", + "subjectSignatureAlgorithm": "Ed25519", + "issuedAt": "2026-08-12T10:00:00Z", + "validFrom": "2026-08-12T10:00:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-invalid-attested-001/status" + }, + "issuerSignature": "OUTER_SIGNATURE", + "issuerSignatureAlgorithm": "Ed25519" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-direct-missing-subject-signature.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-direct-missing-subject-signature.json new file mode 100644 index 0000000..47f03fc --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-direct-missing-subject-signature.json @@ -0,0 +1,37 @@ +{ + "id": "association-credential-direct-missing-subject-signature", + "description": "DIRECT_SIGNATURE requires all three subject inner-signature fields.", + "schema": "association-credential.schema.json", + "expectedValid": false, + "instance": { + "credentialId": "credential-invalid-direct-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-invalid-direct-001", + "agentId": "agent:vector:service-assistant", + "subjectId": "subject:vector:company", + "issuerId": "issuer:vector:reference-service", + "relationshipEvidenceRef": "urn:vector:evidence:company-controls-agent", + "confirmationMethod": "DIRECT_SIGNATURE", + "subjectCreditAssertionRef": "urn:vector:credit-assertion:company-001", + "associatedCreditValue": { + "level": "AA-ASSOCIATED" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "vector-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "Direct confirmation with missing signature fields.", + "purpose": "B2B_COUNTERPARTY_REVIEW", + "scope": [ + "business:vector:procurement" + ], + "issuedAt": "2026-08-12T10:05:00Z", + "validFrom": "2026-08-12T10:05:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-invalid-direct-001/status" + }, + "issuerSignature": "OUTER_SIGNATURE", + "issuerSignatureAlgorithm": "Ed25519" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-wrong-credit-source.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-wrong-credit-source.json new file mode 100644 index 0000000..ce1ef5f --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/association-credential-wrong-credit-source.json @@ -0,0 +1,37 @@ +{ + "id": "association-credential-wrong-credit-source", + "description": "Associated credit must carry the ASSOCIATED_CREDIT source marker.", + "schema": "association-credential.schema.json", + "expectedValid": false, + "instance": { + "credentialId": "credential-invalid-source-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-invalid-source-001", + "agentId": "agent:vector:shopping-assistant", + "subjectId": "subject:vector:alice", + "issuerId": "issuer:vector:reference-service", + "relationshipEvidenceRef": "urn:vector:evidence:alice-operates-agent", + "confirmationMethod": "ATTESTED_CONFIRMATION", + "subjectCreditAssertionRef": "urn:vector:credit-assertion:alice-001", + "associatedCreditValue": { + "level": "A" + }, + "creditSource": "AGENT_INDEPENDENT_CREDIT", + "mappingPolicy": { + "id": "vector-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "Attested confirmation.", + "purpose": "MERCHANT_RISK_REFERENCE", + "scope": [ + "merchant:vector:store" + ], + "issuedAt": "2026-08-12T10:15:00Z", + "validFrom": "2026-08-12T10:15:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-invalid-source-001/status" + }, + "issuerSignature": "OUTER_SIGNATURE", + "issuerSignatureAlgorithm": "Ed25519" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/canonical-ed25519-tampered.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/canonical-ed25519-tampered.json new file mode 100644 index 0000000..9160f82 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/canonical-ed25519-tampered.json @@ -0,0 +1,9 @@ +{ + "id": "canonical-ed25519-tampered", + "description": "The valid reference signature must fail after the canonical payload is changed.", + "algorithm": "Ed25519", + "canonicalPayload": "{\"a\":\"ACP-TAMPERED\",\"nested\":{\"a\":[3,2,1],\"b\":true},\"z\":1}", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAisjuaN/cm2+zxRuis/oJCmsv0UWOjyF+JeaLSheU9CQ=\n-----END PUBLIC KEY-----\n", + "signature": "DyNGvXbGY2EJ-DX2FacPkLLUj63-EshC6IaLTDSpAagbGjp_PIcKg0E8CLmEy2h3okyfjNfhtOplpZ64-MuiAQ", + "expectedValid": false +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/credit-query-authorization-per-request-missing-binding.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/credit-query-authorization-per-request-missing-binding.json new file mode 100644 index 0000000..af4f2ff --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/credit-query-authorization-per-request-missing-binding.json @@ -0,0 +1,34 @@ +{ + "id": "credit-query-authorization-per-request-missing-binding", + "description": "Per-request authorization requires the request identifier it authorizes.", + "schema": "credit-query-authorization.schema.json", + "expectedValid": false, + "instance": { + "authorizationId": "authorization-invalid-per-request-001", + "authorizationVersion": "reference-v1", + "mode": "PER_REQUEST", + "subjectId": "subject:vector:alice", + "relyingPartyIds": [ + "relying-party:vector:merchant" + ], + "agentIds": [ + "agent:vector:shopping-assistant" + ], + "verificationLevel": "ASSOCIATED_CREDIT", + "purpose": "MERCHANT_RISK_REFERENCE", + "allowedDataItems": [ + "associatedCreditValue" + ], + "validFrom": "2026-08-12T10:25:00Z", + "expiresAt": "2026-08-12T10:30:00Z", + "resultUseRestrictions": { + "mayStore": false, + "mayTransfer": false + }, + "status": "ACTIVE", + "authorizationProof": { + "signatureAlgorithm": "Ed25519", + "signatureValue": "VECTOR_AUTHORIZATION_SIGNATURE" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/credit-query-authorization-platform-missing-frequency.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/credit-query-authorization-platform-missing-frequency.json new file mode 100644 index 0000000..24861af --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/credit-query-authorization-platform-missing-frequency.json @@ -0,0 +1,35 @@ +{ + "id": "credit-query-authorization-platform-missing-frequency", + "description": "Platform-delegated authorization requires a machine-readable frequency limit.", + "schema": "credit-query-authorization.schema.json", + "expectedValid": false, + "instance": { + "authorizationId": "authorization-invalid-platform-001", + "authorizationVersion": "reference-v1", + "mode": "PLATFORM_DELEGATED", + "subjectId": "subject:vector:company", + "relyingPartyIds": [ + "relying-party:vector:procurement" + ], + "platformDelegateId": "platform-delegate:vector:procurement", + "agentIds": [ + "agent:vector:service-assistant" + ], + "verificationLevel": "ASSOCIATED_CREDIT", + "purpose": "B2B_COUNTERPARTY_REVIEW", + "allowedDataItems": [ + "associatedCreditValue" + ], + "validFrom": "2026-08-12T10:20:00Z", + "expiresAt": "2026-09-12T10:20:00Z", + "resultUseRestrictions": { + "mayStore": false, + "mayTransfer": false + }, + "status": "ACTIVE", + "authorizationProof": { + "signatureAlgorithm": "Ed25519", + "signatureValue": "VECTOR_AUTHORIZATION_SIGNATURE" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/status-change-revoked-to-active.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/status-change-revoked-to-active.json new file mode 100644 index 0000000..d7a52a6 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/status-change-revoked-to-active.json @@ -0,0 +1,10 @@ +{ + "id": "status-change-revoked-to-active", + "description": "REVOKED is terminal and cannot transition back to ACTIVE.", + "operation": "lifecycleTransition", + "input": { + "fromStatus": "REVOKED", + "toStatus": "ACTIVE" + }, + "expectedAllowed": false +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-request-associated-credit-missing-authorization.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-request-associated-credit-missing-authorization.json new file mode 100644 index 0000000..a90fd77 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-request-associated-credit-missing-authorization.json @@ -0,0 +1,31 @@ +{ + "id": "verification-request-associated-credit-missing-authorization", + "description": "Associated-credit information verification requires a subject credit assertion reference and valid query authorization reference.", + "schema": "verification-request.schema.json", + "expectedValid": false, + "instance": { + "requestId": "verification-invalid-authorization-001", + "messageVersion": "reference-v1", + "relyingPartyId": "relying-party:vector:merchant", + "agentId": "agent:vector:shopping-assistant", + "verificationLevel": "ASSOCIATED_CREDIT", + "purpose": "MERCHANT_RISK_REFERENCE", + "businessContext": { + "orderId": "order-vector-001" + }, + "requestedDataItems": [ + "associatedCreditValue" + ], + "credential": { + "credentialId": "credential-attested-vector-001" + }, + "requestedAt": "2026-08-12T10:30:00Z", + "antiReplay": { + "nonce": "verification-invalid-authorization-nonce" + }, + "requestProof": { + "signatureAlgorithm": "Ed25519", + "signatureValue": "VECTOR_REQUEST_SIGNATURE" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-response-credential-leaks-credit.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-response-credential-leaks-credit.json new file mode 100644 index 0000000..81949f7 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-response-credential-leaks-credit.json @@ -0,0 +1,29 @@ +{ + "id": "verification-response-credential-leaks-credit", + "description": "Credential-level response must not disclose mapped credit data.", + "schema": "verification-response.schema.json", + "expectedValid": false, + "instance": { + "requestId": "verification-invalid-disclosure-001", + "verificationRecordId": "verification-record-invalid-disclosure-001", + "agentId": "agent:vector:shopping-assistant", + "completedLevel": "CREDENTIAL", + "result": "PASS", + "reasonCode": "VERIFIED", + "credentialStatus": "ACTIVE", + "creditSource": "ASSOCIATED_CREDIT", + "associatedCreditValue": { + "level": "A-ASSOCIATED" + }, + "scope": [ + "merchant:vector:store" + ], + "generatedAt": "2026-08-12T10:35:00Z", + "expiresAt": "2026-08-12T10:40:00Z", + "purposeLimited": true, + "responseProof": { + "signatureAlgorithm": "Ed25519", + "signatureValue": "VECTOR_RESPONSE_SIGNATURE" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-response-pass-with-revoked-credential.json b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-response-pass-with-revoked-credential.json new file mode 100644 index 0000000..5a3b4a4 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/invalid/verification-response-pass-with-revoked-credential.json @@ -0,0 +1,25 @@ +{ + "id": "verification-response-pass-with-revoked-credential", + "description": "A PASS response requires the credential to be ACTIVE.", + "schema": "verification-response.schema.json", + "expectedValid": false, + "instance": { + "requestId": "verification-invalid-status-001", + "verificationRecordId": "verification-record-invalid-status-001", + "agentId": "agent:vector:shopping-assistant", + "completedLevel": "CREDENTIAL", + "result": "PASS", + "reasonCode": "VERIFIED", + "credentialStatus": "REVOKED", + "scope": [ + "merchant:vector:store" + ], + "generatedAt": "2026-08-12T10:40:00Z", + "expiresAt": "2026-08-12T10:45:00Z", + "purposeLimited": true, + "responseProof": { + "signatureAlgorithm": "Ed25519", + "signatureValue": "VECTOR_RESPONSE_SIGNATURE" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/association-credential-attested-outer-signature-only.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/association-credential-attested-outer-signature-only.json new file mode 100644 index 0000000..08df0fa --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/association-credential-attested-outer-signature-only.json @@ -0,0 +1,40 @@ +{ + "id": "association-credential-attested-outer-signature-only", + "description": "ATTESTED_CONFIRMATION carries the credential issuer outer signature and omits every subject inner-signature field.", + "schema": "association-credential.schema.json", + "expectedValid": true, + "instance": { + "credentialId": "credential-attested-vector-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-attested-vector-001", + "agentId": "agent:vector:shopping-assistant", + "subjectId": "subject:vector:alice", + "associationRole": "OPERATOR", + "issuerId": "issuer:vector:reference-service", + "relationshipEvidenceRef": "urn:vector:evidence:alice-operates-agent", + "confirmationMethod": "ATTESTED_CONFIRMATION", + "subjectCreditAssertionRef": "urn:vector:credit-assertion:alice-001", + "associatedCreditValue": { + "level": "A-ASSOCIATED" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "vector-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "The subject confirmed this OPERATOR association through an attested interaction.", + "purpose": "MERCHANT_RISK_REFERENCE", + "scope": [ + "merchant:vector:store" + ], + "issuedAt": "2026-08-12T09:00:00Z", + "validFrom": "2026-08-12T09:00:00Z", + "expiresAt": "2027-08-12T09:00:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-attested-vector-001/status", + "method": "GET" + }, + "issuerSignature": "VECTOR_ISSUER_OUTER_SIGNATURE", + "issuerSignatureAlgorithm": "Ed25519" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/association-credential-direct-two-signatures.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/association-credential-direct-two-signatures.json new file mode 100644 index 0000000..4112357 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/association-credential-direct-two-signatures.json @@ -0,0 +1,51 @@ +{ + "id": "association-credential-direct-two-signatures", + "description": "DIRECT_SIGNATURE carries subject public key, subject inner signature and credential issuer outer signature.", + "schema": "association-credential.schema.json", + "expectedValid": true, + "instance": { + "credentialId": "credential-direct-vector-001", + "credentialVersion": "reference-v1", + "associationApplicationId": "application-direct-vector-001", + "agentId": "agent:vector:service-assistant", + "subjectId": "subject:vector:company", + "associationRole": "CONTROLLER", + "issuerId": "issuer:vector:reference-service", + "relationshipEvidenceRef": "urn:vector:evidence:company-controls-agent", + "confirmationMethod": "DIRECT_SIGNATURE", + "subjectCreditAssertionRef": "urn:vector:credit-assertion:company-001", + "associatedCreditValue": { + "level": "AA-ASSOCIATED" + }, + "creditSource": "ASSOCIATED_CREDIT", + "mappingPolicy": { + "id": "vector-level-mapping", + "version": "1.0" + }, + "confirmationStatement": "The subject directly signed this CONTROLLER association.", + "purpose": "B2B_COUNTERPARTY_REVIEW", + "scope": [ + "business:vector:procurement" + ], + "associationApplicationRequestedAt": "2026-08-12T09:00:00Z", + "associationApplicationAntiReplay": { + "nonce": "direct-vector-nonce-001" + }, + "subjectPublicKey": { + "keyId": "subject:vector:company#key-1", + "format": "pem-spki", + "value": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAnVxhbm15980QOUSGDtbWucoUw6VxyAOv/fHlUhvohVM=\n-----END PUBLIC KEY-----\n" + }, + "subjectSignature": "VECTOR_SUBJECT_INNER_SIGNATURE", + "subjectSignatureAlgorithm": "Ed25519", + "issuedAt": "2026-08-12T09:05:00Z", + "validFrom": "2026-08-12T09:05:00Z", + "expiresAt": "2027-08-12T09:05:00Z", + "statusQuery": { + "uri": "/v1/association-credentials/credential-direct-vector-001/status", + "method": "GET" + }, + "issuerSignature": "VECTOR_ISSUER_OUTER_SIGNATURE", + "issuerSignatureAlgorithm": "Ed25519" + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/canonical-ed25519-verification.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/canonical-ed25519-verification.json new file mode 100644 index 0000000..de26ad3 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/canonical-ed25519-verification.json @@ -0,0 +1,9 @@ +{ + "id": "canonical-ed25519-verification", + "description": "Fixed Ed25519 verification vector over recursively key-sorted compact JSON. It includes public verification material only.", + "algorithm": "Ed25519", + "canonicalPayload": "{\"a\":\"ACP\",\"nested\":{\"a\":[3,2,1],\"b\":true},\"z\":1}", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAisjuaN/cm2+zxRuis/oJCmsv0UWOjyF+JeaLSheU9CQ=\n-----END PUBLIC KEY-----\n", + "signature": "DyNGvXbGY2EJ-DX2FacPkLLUj63-EshC6IaLTDSpAagbGjp_PIcKg0E8CLmEy2h3okyfjNfhtOplpZ64-MuiAQ", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/credit-query-authorization-platform-delegated.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/credit-query-authorization-platform-delegated.json new file mode 100644 index 0000000..80e124e --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/credit-query-authorization-platform-delegated.json @@ -0,0 +1,42 @@ +{ + "id": "credit-query-authorization-platform-delegated", + "description": "Platform-delegated authorization identifies the delegate and provides a machine-readable frequency limit.", + "schema": "credit-query-authorization.schema.json", + "expectedValid": true, + "instance": { + "authorizationId": "authorization-vector-platform-001", + "authorizationVersion": "reference-v1", + "mode": "PLATFORM_DELEGATED", + "subjectId": "subject:vector:company", + "relyingPartyIds": [ + "relying-party:vector:procurement" + ], + "platformDelegateId": "platform-delegate:vector:procurement", + "agentIds": [ + "agent:vector:service-assistant" + ], + "verificationLevel": "ASSOCIATED_CREDIT", + "purpose": "B2B_COUNTERPARTY_REVIEW", + "allowedDataItems": [ + "associatedCreditValue", + "mappingPolicy" + ], + "validFrom": "2026-08-12T09:10:00Z", + "expiresAt": "2026-09-12T09:10:00Z", + "frequencyLimit": { + "maxRequestsPerWindow": 5, + "windowDurationSeconds": 3600 + }, + "resultUseRestrictions": { + "mayStore": true, + "retentionSeconds": 86400, + "mayTransfer": false + }, + "status": "ACTIVE", + "authorizationProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "subject:vector:company#key-1", + "signatureValue": "VECTOR_AUTHORIZATION_SIGNATURE" + } + } +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-agent-not-registered.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-agent-not-registered.json new file mode 100644 index 0000000..da06a1f --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-agent-not-registered.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-agent-not-registered", + "description": "Verification for an unknown Agent identifier returns AGENT_NOT_REGISTERED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "agent identifier is not registered" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "AGENT_NOT_REGISTERED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-credential-not-active.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-credential-not-active.json new file mode 100644 index 0000000..8e9b818 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-credential-not-active.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-association-credential-not-active", + "description": "A suspended, revoked or expired association credential returns ASSOCIATION_CREDENTIAL_NOT_ACTIVE.", + "operation": "verificationReasonCode", + "input": { + "scenario": "association credential status is not ACTIVE" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "ASSOCIATION_CREDENTIAL_NOT_ACTIVE", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-credential-not-found.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-credential-not-found.json new file mode 100644 index 0000000..b6f2a52 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-credential-not-found.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-association-credential-not-found", + "description": "A missing association credential returns ASSOCIATION_CREDENTIAL_NOT_FOUND.", + "operation": "verificationReasonCode", + "input": { + "scenario": "referenced association credential does not exist" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "ASSOCIATION_CREDENTIAL_NOT_FOUND", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-proof-invalid.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-proof-invalid.json new file mode 100644 index 0000000..ddb5b1d --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-association-proof-invalid.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-association-proof-invalid", + "description": "An association credential with an invalid signature returns ASSOCIATION_PROOF_INVALID.", + "operation": "verificationReasonCode", + "input": { + "scenario": "association credential proof verification fails" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "ASSOCIATION_PROOF_INVALID", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-expired.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-expired.json new file mode 100644 index 0000000..85b186c --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-expired.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-authorization-expired", + "description": "Use of an authorization after its validity period returns AUTHORIZATION_EXPIRED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "query authorization has expired" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "AUTHORIZATION_EXPIRED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-required.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-required.json new file mode 100644 index 0000000..f37af8b --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-required.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-authorization-required", + "description": "Associated-credit verification without a query authorization returns AUTHORIZATION_REQUIRED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "associated-credit verification omits authorization" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "AUTHORIZATION_REQUIRED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-revoked.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-revoked.json new file mode 100644 index 0000000..0f56a55 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-revoked.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-authorization-revoked", + "description": "Use of a revoked authorization returns AUTHORIZATION_REVOKED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "query authorization has been revoked" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "AUTHORIZATION_REVOKED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-scope-mismatch.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-scope-mismatch.json new file mode 100644 index 0000000..28e5fd6 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-authorization-scope-mismatch.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-authorization-scope-mismatch", + "description": "A request outside the authorization purpose, relying party, agent, level or data scope returns AUTHORIZATION_SCOPE_MISMATCH.", + "operation": "verificationReasonCode", + "input": { + "scenario": "verification request exceeds authorization scope" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "AUTHORIZATION_SCOPE_MISMATCH", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-credit-assertion-invalid.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-credit-assertion-invalid.json new file mode 100644 index 0000000..446bfdc --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-credit-assertion-invalid.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-credit-assertion-invalid", + "description": "An invalid subject credit assertion returns CREDIT_ASSERTION_INVALID.", + "operation": "verificationReasonCode", + "input": { + "scenario": "subject credit assertion proof or validity check fails" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "CREDIT_ASSERTION_INVALID", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-credit-assertion-unavailable.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-credit-assertion-unavailable.json new file mode 100644 index 0000000..76b344c --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-credit-assertion-unavailable.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-credit-assertion-unavailable", + "description": "A temporarily unavailable subject credit assertion returns CREDIT_ASSERTION_UNAVAILABLE.", + "operation": "verificationReasonCode", + "input": { + "scenario": "subject credit assertion cannot currently be retrieved" + }, + "expectedResult": "INCONCLUSIVE", + "expectedReasonCode": "CREDIT_ASSERTION_UNAVAILABLE", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-inconclusive.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-inconclusive.json new file mode 100644 index 0000000..69550bf --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-inconclusive.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-inconclusive", + "description": "An undetermined verification outcome returns INCONCLUSIVE.", + "operation": "verificationReasonCode", + "input": { + "scenario": "verification cannot reach a deterministic result" + }, + "expectedResult": "INCONCLUSIVE", + "expectedReasonCode": "INCONCLUSIVE", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-invalid-request.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-invalid-request.json new file mode 100644 index 0000000..9dd948f --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-invalid-request.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-invalid-request", + "description": "A malformed or semantically invalid verification request returns INVALID_REQUEST.", + "operation": "verificationReasonCode", + "input": { + "scenario": "verification request fails input validation" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "INVALID_REQUEST", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-mapping-policy-unsupported.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-mapping-policy-unsupported.json new file mode 100644 index 0000000..538f7e8 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-mapping-policy-unsupported.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-mapping-policy-unsupported", + "description": "An unsupported mapping policy returns MAPPING_POLICY_UNSUPPORTED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "credential mapping policy is not supported by the verifier" + }, + "expectedResult": "INCONCLUSIVE", + "expectedReasonCode": "MAPPING_POLICY_UNSUPPORTED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-replay-detected.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-replay-detected.json new file mode 100644 index 0000000..187b889 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-replay-detected.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-replay-detected", + "description": "Reuse of an accepted anti-replay value returns REPLAY_DETECTED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "anti-replay nonce has already been consumed" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "REPLAY_DETECTED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-request-expired.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-request-expired.json new file mode 100644 index 0000000..55b3e5a --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-request-expired.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-request-expired", + "description": "A verification request outside its accepted time window returns REQUEST_EXPIRED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "request timestamp is outside the accepted time window" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "REQUEST_EXPIRED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-request-proof-invalid.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-request-proof-invalid.json new file mode 100644 index 0000000..60afd44 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-request-proof-invalid.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-request-proof-invalid", + "description": "A request whose relying-party proof cannot be verified returns REQUEST_PROOF_INVALID.", + "operation": "verificationReasonCode", + "input": { + "scenario": "request proof verification fails" + }, + "expectedResult": "FAIL", + "expectedReasonCode": "REQUEST_PROOF_INVALID", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-review-required.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-review-required.json new file mode 100644 index 0000000..e307709 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-review-required.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-review-required", + "description": "A verification outcome that needs manual handling returns REVIEW_REQUIRED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "verification policy requires manual review" + }, + "expectedResult": "REVIEW_REQUIRED", + "expectedReasonCode": "REVIEW_REQUIRED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-verified.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-verified.json new file mode 100644 index 0000000..fd57a70 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/reason-code-verified.json @@ -0,0 +1,11 @@ +{ + "id": "reason-code-verified", + "description": "Successful verification returns VERIFIED.", + "operation": "verificationReasonCode", + "input": { + "scenario": "all requested verification checks pass" + }, + "expectedResult": "PASS", + "expectedReasonCode": "VERIFIED", + "expectedValid": true +} diff --git a/code/schemas/tsd-crd/reference-v1/test-vectors/valid/verification-response-credential-minimal-disclosure.json b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/verification-response-credential-minimal-disclosure.json new file mode 100644 index 0000000..1db07a5 --- /dev/null +++ b/code/schemas/tsd-crd/reference-v1/test-vectors/valid/verification-response-credential-minimal-disclosure.json @@ -0,0 +1,26 @@ +{ + "id": "verification-response-credential-minimal-disclosure", + "description": "Credential-level verification omits associated credit value and subject credit assertion details.", + "schema": "verification-response.schema.json", + "expectedValid": true, + "instance": { + "requestId": "verification-vector-credential-001", + "verificationRecordId": "verification-record-vector-001", + "agentId": "agent:vector:shopping-assistant", + "completedLevel": "CREDENTIAL", + "result": "PASS", + "reasonCode": "VERIFIED", + "credentialStatus": "ACTIVE", + "scope": [ + "merchant:vector:store" + ], + "generatedAt": "2026-08-12T09:15:00Z", + "expiresAt": "2026-08-12T09:20:00Z", + "purposeLimited": true, + "responseProof": { + "signatureAlgorithm": "Ed25519", + "keyId": "verifier:vector:service#key-1", + "signatureValue": "VECTOR_RESPONSE_SIGNATURE" + } + } +} diff --git a/docs/README.md b/docs/README.md index 4c54088..00d1629 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,3 +14,5 @@ This directory is the human-readable ACT 2.1 documentation set. The normative re - [FAQ](faq.md) Protocol requirements are defined only by the specification documents. Runnable artifacts are under [`code/`](../code/README.md), and product-specific implementations are under [`integrations/`](../integrations/README.md). + +The non-normative TSD-CRD machine profile and runnable reference implementation are under [`code/schemas/tsd-crd/`](../code/schemas/tsd-crd/README.md) and [`code/samples/tsd-crd-reference/`](../code/samples/tsd-crd-reference/README.md). diff --git a/docs/faq.md b/docs/faq.md index ad9963e..02eff62 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -14,7 +14,11 @@ No. It is an explanatory user interface for protocol flows. ## Where are the machine-readable schemas? -Under [`code/schemas/a402/`](../code/schemas/a402/README.md). They are implementation aids and do not add requirements absent from the ACT 2.1 text. +Under [`code/schemas/`](../code/schemas/README.md). It currently contains A402 artifacts and the optional TSD-CRD `reference-v1` implementation profile. They are implementation aids and do not add requirements absent from the ACT 2.1 text. + +## Is the TSD-CRD Sandbox a production credit service? + +No. [`code/samples/tsd-crd-reference/`](../code/samples/tsd-crd-reference/README.md) is a non-normative, non-production reference implementation using Mock identity/credit providers, in-memory state, and temporary test keys. Its tests and basic conformance runner do not prove full ACT 2.1 conformance or production readiness. ## Where do I configure an Alipay sandbox? diff --git a/docs/flows/scenarios.md b/docs/flows/scenarios.md index 6ce4ada..b6daaeb 100644 --- a/docs/flows/scenarios.md +++ b/docs/flows/scenarios.md @@ -4,7 +4,7 @@ > **用于理解跨域组合,不是独立协议组件、正式实现规范或 Conformance 证据。** > **版本基线:2026-08-11(UTC+8)。** -本文把 ADD、CID、PSD 和 TSD 组合成端到端业务场景,帮助开发者判断何时需要 IAC、采用哪一种支付授权级别,以及何时异步形成可信事件。本 Release 中的本文是 ACT 2.1 的版本化场景指南;ACT Protocol 官网的[典型场景与业务流程](https://www.act-protocol.com/documentation/scenarios)是持续更新的公开入口。 +本文把 ADD、CID、PSD 和 TSD 组合成端到端业务场景,帮助开发者判断何时需要 IAC、采用哪一种支付授权级别、何时异步形成可信事件,以及何时按需验证关联信用。本 Release 中的本文是 ACT 2.1 的版本化场景指南;ACT Protocol 官网的[典型场景与业务流程](https://www.act-protocol.com/documentation/scenarios)是持续更新的公开入口。 ACT 2.1 在场景分类和组件清单中明确 L1/L2/L3,并列入 `PSD-PAY-A402`。本指南与[支付服务域](../specification/payment-services.md)一致:A402 是可被 INS、DEL、AUP 引用的独立接入组件,不是新的授权等级。若场景说明与域正文发生冲突,以对应域正文为准。 @@ -26,13 +26,15 @@ flowchart LR ADD["ADD:意图、ISR、IAC 与状态"] --> CID["CID:发现、意图传递、支付协商与交易确认"] CID --> PSD["PSD:支付工具、授权核验、支付与 A402"] PSD --> FUL["业务履约或资源交付"] - ADD -. "intent_id / delegation_id" .-> TSD["TSD:异步事件、证据、核验与争议"] + ADD -. "intent_id / delegation_id" .-> TSD["TSD:可信存证与关联信用"] CID -. "order / decision" .-> TSD PSD -. "payment transaction" .-> TSD FUL -. "fulfillment" .-> TSD ``` -TSD 上报是异步、非阻塞的附加流程。未完成 TSD 上报不应使已经满足 ADD/CID/PSD 条件的在线支付停在主链路;相反,业务或支付失败也不得伪造完成事件。 +`TSD-ATT` 存证上报是异步、非阻塞的附加流程。未完成存证上报不应使已经满足 ADD/CID/PSD 条件的在线支付停在主链路;相反,业务或支付失败也不得伪造完成事件。 + +`TSD-CRD-VER` 是按需信用验证,可在业务自己的风险判断阶段同步调用,也可以离线使用;它不属于存证上报链路。其 `PASS` 只表示指定范围、目的和时点下的关联信用验证通过,不构成 IAC、交易准入、授信或支付批准。 ## 3. 场景一:用户在场的即时支付 @@ -114,7 +116,7 @@ TSD 上报是异步、非阻塞的附加流程。未完成 TSD 上报不应使 - 首期真实产品接入优先实现 L1:`ADD-INT-ICS + CID-CART-CFM + PSD-PMT-BND + PSD-PAY-INS + PSD-PAY-A402`。 - 实现 L2/L3 前,需要同时具备 IAC 签发、状态查询、Agent 身份/密钥、PSP 权威授权核验和完整异常恢复,不能只在请求中增加 `delegation_id`。 -- TSD 规范语义已经定稿,但本仓库没有可运行的 ACT Trust Chain 或信用服务。产品日志和沙箱证据可以作为未来映射输入,但不得据此声明已实现 TSD。 +- TSD 规范语义已经定稿,但本仓库没有可运行的 ACT Trust Chain 或生产信用服务。[TSD-CRD Reference Implementation](../../code/samples/tsd-crd-reference/README.md) 只使用 Mock 能力和内存状态;产品日志、Demo 或基础一致性结果不得据此声明已完成 TSD 全量 Conformance 或生产接入。 ## 9. 来源 diff --git a/docs/glossary.md b/docs/glossary.md index 0d9e37d..6780315 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -9,6 +9,8 @@ | CID | 商业交互域 | Commerce Interaction Domain | | PSD | 支付服务域 | Payment Services Domain | | TSD | 信任服务域 | Trust Services Domain | +| TSD-CRD | 信用关联子篇 | Credit Association subprotocol within the ACT 2.1 Trust Services Domain | +| `ASSOCIATED_CREDIT` | 关联信用来源标记 | Marks credit information mapped from a verified associated subject; it is not the Agent's independent credit or reputation | | Principal / Delegator | 委托人 | The user or principal whose intent and authority the Agent acts upon | | Buyer Agent | 买方智能体 | The Agent that acts for the principal to discover, negotiate and purchase | | Seller Service | 卖方服务方 | The merchant, service, Agent or endpoint that protects and delivers a paid resource | diff --git a/docs/specification/README.md b/docs/specification/README.md index ec662f1..0b71227 100644 --- a/docs/specification/README.md +++ b/docs/specification/README.md @@ -10,4 +10,4 @@ ACT 2.1 is organized into four domains and their cross-domain payment access rul 6. [A402 payment access protocol](a402.md) 7. [Commerce-to-payment negotiation](commerce-payment-negotiation.md) -The [scenario guide](../flows/scenarios.md) is informative. JSON Schemas and fixtures are implementation aids under [`code/schemas/`](../../code/schemas/README.md). +The [scenario guide](../flows/scenarios.md) is informative. JSON Schemas, examples, and test vectors are implementation aids under [`code/schemas/`](../../code/schemas/README.md); the TSD-CRD `reference-v1` profile is explicitly non-normative. diff --git a/docs/specification/trust-services.md b/docs/specification/trust-services.md index b9ce694..f84a2ac 100644 --- a/docs/specification/trust-services.md +++ b/docs/specification/trust-services.md @@ -207,15 +207,16 @@ ACT 2.1 指定 ACT Trust Chain 为锚定基础设施,但没有定义可直接 ## 14. 当前机器契约与实现边界 -两份来源给出了完整语义、字段存在性、状态和原因码,但以下内容仍未形成可声明正式兼容的公开机器契约: +ACT 2.1 已冻结信用关联的业务语义、对象必备性、状态和原因码,但没有冻结整个 TSD 统一的英文 wire 字段名、规范性 JSON Schema、版本协商、错误封装或统一 HTTP 接口。以下内容仍不构成 ACT 2.1 的正式机器契约: -- TSD 统一对象的英文 wire 字段名、JSON Schema、版本协商和错误封装; - ACT Trust Chain 公开网络、节点接口、认证、隐私通道和正式 JWS 载荷 Schema; - 每种 `event_body` 的字段级 Schema 与事件提交/查询接口; -- 信用关联凭证、信用声明、查询授权、验证请求/响应的正式封装与算法套件; -- 身份解析、历史公钥获取、状态查询和映射规则注册协议。 +- TSD 各子篇共用的身份解析、历史公钥获取、状态查询和注册协议; +- 全域统一的算法协商、传输封装和 Conformance Profile。 -因此当前仓库不生成虚构的 TSD Schema、节点实现、信用模型或“链上沙箱”。 +仓库在 [`code/schemas/tsd-crd/reference-v1`](../../code/schemas/tsd-crd/reference-v1/README.md) 提供一套明确标为非规范性的 TSD-CRD Reference Profile,并在 [`code/samples/tsd-crd-reference`](../../code/samples/tsd-crd-reference/README.md) 提供本地 Reference Implementation。该 Profile 选择 `camelCase` 字段、Ed25519、确定性 JSON 签名投影和本地 HTTP 绑定,只是可选实现约定,不能反向解释为 ACT 2.1 要求或 TSD 统一 wire 契约。 + +参考实现只使用 Mock 身份/信用/映射、内存存储和临时测试密钥。Schema 校验、测试和基础一致性 Runner 通过,仅证明当前 `reference-v1` 路径满足仓库内已执行检查;不等于 ACT 2.1 全量 Conformance、真实信用服务、ACT Trust Chain 节点或生产安全证明。仓库仍不虚构信用模型、链上网络或生产身份/密钥基础设施。 ## 15. 来源 diff --git a/release-manifest.json b/release-manifest.json index 343dae6..96d3ac0 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -3,11 +3,13 @@ "release": "ACT 2.1", "release_date": "2026-08-14", "specification_finalized": "2026-08-11", - "updated": "2026-08-17", + "updated": "2026-08-24", "components": { "specification": {"path": "docs/specification/overview.md", "status": "final", "normative": true}, "a402-machine-artifacts": {"path": "code/schemas/a402/README.md", "status": "implementation-artifact", "normative": false}, + "tsd-crd-reference-profile": {"path": "code/schemas/tsd-crd/reference-v1/README.md", "status": "implementation-artifact", "normative": false}, "local-a402-sample": {"path": "code/samples/local-a402/README.md", "status": "sample", "normative": false}, + "tsd-crd-reference-implementation": {"path": "code/samples/tsd-crd-reference/README.md", "status": "reference-implementation", "normative": false}, "alipay-buyer-integration": {"path": "integrations/alipay/buyer-agent/README.md", "status": "reference-integration", "normative": false}, "alipay-seller-integration": {"path": "integrations/alipay/seller-java/README.md", "status": "reference-integration", "normative": false}, "alipay-validation": {"path": "integrations/alipay/validation/README.md", "status": "validation-guidance", "normative": false}, diff --git a/tools/README.md b/tools/README.md index 7423b71..db1e12a 100644 --- a/tools/README.md +++ b/tools/README.md @@ -6,5 +6,7 @@ This directory contains maintainer and CI tooling for the repository. These file - `quality/check_repository.py`: repository structure, links, JSON, Python and release-wording checks. - `quality/create_public_snapshot.py`: public-release snapshot and sensitive-content checks. - `a402/validate_contract.py`: validation for the non-normative A402 schemas, fixtures and executable assertions. +- `tsd-crd/validate_contract.py`: validation for the non-normative TSD-CRD + `reference-v1` schemas, examples, OpenAPI references, and test-vector layout. Tools may validate protocol artifacts, but they do not define ACT semantics. Normative text remains under `docs/specification/`; machine-readable implementation artifacts remain under `code/schemas/`. diff --git a/tools/quality/check_repository.py b/tools/quality/check_repository.py index 90cae69..b30d3ea 100644 --- a/tools/quality/check_repository.py +++ b/tools/quality/check_repository.py @@ -10,7 +10,7 @@ from urllib.parse import unquote, urlparse ROOT = Path(__file__).resolve().parents[2] -IGNORED = {".git", ".tmp", ".venv", "node_modules", "__pycache__", "output", "target"} +IGNORED = {".git", ".tmp", ".venv", ".codefuse", "node_modules", "__pycache__", "output", "target"} LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") @@ -55,13 +55,17 @@ def structure_errors() -> list[str]: "README.md", "README.en.md", "LICENSE", "SECURITY.md", "release-manifest.json", "docs/specification/overview.md", "docs/specification/a402.md", "docs/flows/scenarios.md", "code/schemas/a402/README.md", "code/schemas/a402/payment-needed.schema.json", + "code/schemas/tsd-crd/reference-v1/README.md", + "code/schemas/tsd-crd/reference-v1/schemas/association-credential.schema.json", "code/samples/local-a402/package.json", "code/web-client/alipay-ai-pay-showcase/package.json", + "code/samples/tsd-crd-reference/package.json", "integrations/alipay/buyer-agent/package.json", "integrations/alipay/seller-java/pom.xml", "integrations/alipay/validation/README.md", "tools/verify.sh", "tools/quality/check_repository.py", "tools/quality/create_public_snapshot.py", "tools/a402/validate_contract.py", + "tools/tsd-crd/validate_contract.py", ] errors = [f"missing required asset: {p}" for p in required if not (ROOT / p).is_file()] forbidden = [ diff --git a/tools/tsd-crd/validate_contract.py b/tools/tsd-crd/validate_contract.py new file mode 100644 index 0000000..f568879 --- /dev/null +++ b/tools/tsd-crd/validate_contract.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Dependency-free integrity checks for non-normative TSD-CRD artifacts.""" + +from __future__ import annotations + +import json +import re +import sys +from datetime import datetime +from pathlib import Path +from urllib.parse import urlsplit + + +ROOT = Path(__file__).resolve().parents[2] +PROFILE_DIR = ROOT / "code/schemas/tsd-crd/reference-v1" +SCHEMA_DIR = PROFILE_DIR / "schemas" +EXAMPLE_DIR = PROFILE_DIR / "examples" +VECTOR_DIR = PROFILE_DIR / "test-vectors" +OPENAPI_PATH = PROFILE_DIR / "openapi/openapi.yaml" + +SCHEMAS = { + "agent-associated-credit-assertion.schema.json", + "association-application.schema.json", + "association-credential.schema.json", + "authorization-revocation-request.schema.json", + "common.schema.json", + "credential-status.schema.json", + "credit-query-authorization.schema.json", + "direct-signing-payload.schema.json", + "status-change-request.schema.json", + "subject-credit-assertion.schema.json", + "verification-request.schema.json", + "verification-response.schema.json", +} + +EXAMPLES = { + "agent-associated-credit-assertion.json": "agent-associated-credit-assertion.schema.json", + "association-application-attested.json": "association-application.schema.json", + "association-application-direct.json": "association-application.schema.json", + "association-credential-attested.json": "association-credential.schema.json", + "association-credential-direct.json": "association-credential.schema.json", + "credential-status-active.json": "credential-status.schema.json", + "credit-query-authorization-per-request.json": "credit-query-authorization.schema.json", + "credit-query-authorization-platform-delegated.json": "credit-query-authorization.schema.json", + "status-change-suspend.json": "status-change-request.schema.json", + "subject-credit-assertion.json": "subject-credit-assertion.schema.json", + "verification-request-associated-credit.json": "verification-request.schema.json", + "verification-request-credential.json": "verification-request.schema.json", + "verification-response-associated-credit-pass.json": "verification-response.schema.json", + "verification-response-credential-pass.json": "verification-response.schema.json", + "verification-response-inconclusive.json": "verification-response.schema.json", +} + + +def load_json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def json_pointer(document, pointer: str): + value = document + if not pointer: + return value + if not pointer.startswith("/"): + raise ValueError(f"unsupported JSON pointer: {pointer}") + for raw_part in pointer[1:].split("/"): + part = raw_part.replace("~1", "/").replace("~0", "~") + value = value[int(part)] if isinstance(value, list) else value[part] + return value + + +def type_matches(value, expected: str) -> bool: + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + if expected == "null": + return value is None + raise ValueError(f"unsupported JSON Schema type: {expected}") + + +def format_matches(value: str, name: str) -> bool: + if name == "date-time": + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + return "T" in value and (value.endswith("Z") or "+" in value[10:]) + except ValueError: + return False + if name == "uri": + parsed = urlsplit(value) + return bool(parsed.scheme and not any(character.isspace() for character in value)) + if name == "uri-reference": + return bool(value) and not any(character.isspace() for character in value) + raise ValueError(f"unsupported JSON Schema format: {name}") + + +def validate(instance, schema, document_path: Path, at: str = "$") -> list[str]: + if schema is True: + return [] + if schema is False: + return [f"{at}: false schema rejects the value"] + + errors: list[str] = [] + if "$ref" in schema: + ref_document, _, fragment = schema["$ref"].partition("#") + target_path = document_path if not ref_document else (document_path.parent / ref_document).resolve() + target_document = load_json(target_path) + errors.extend(validate(instance, json_pointer(target_document, fragment), target_path, at)) + + for subschema in schema.get("allOf", []): + errors.extend(validate(instance, subschema, document_path, at)) + + if "anyOf" in schema: + branches = [validate(instance, item, document_path, at) for item in schema["anyOf"]] + if all(branch for branch in branches): + errors.append(f"{at}: value does not match any anyOf branch") + + if "oneOf" in schema: + matches = sum(not validate(instance, item, document_path, at) for item in schema["oneOf"]) + if matches != 1: + errors.append(f"{at}: value matches {matches} oneOf branches, expected exactly one") + + if "not" in schema and not validate(instance, schema["not"], document_path, at): + errors.append(f"{at}: value matches a forbidden schema") + + if "if" in schema: + branch = "then" if not validate(instance, schema["if"], document_path, at) else "else" + if branch in schema: + errors.extend(validate(instance, schema[branch], document_path, at)) + + expected_type = schema.get("type") + if expected_type is not None: + options = expected_type if isinstance(expected_type, list) else [expected_type] + if not any(type_matches(instance, option) for option in options): + return errors + [f"{at}: expected type {expected_type!r}, got {type(instance).__name__}"] + + if "const" in schema and instance != schema["const"]: + errors.append(f"{at}: expected constant {schema['const']!r}") + if "enum" in schema and instance not in schema["enum"]: + errors.append(f"{at}: value {instance!r} is not in the allowed enum") + + if isinstance(instance, str): + if len(instance) < schema.get("minLength", 0): + errors.append(f"{at}: string is shorter than minLength") + if len(instance) > schema.get("maxLength", sys.maxsize): + errors.append(f"{at}: string is longer than maxLength") + if "pattern" in schema and re.search(schema["pattern"], instance) is None: + errors.append(f"{at}: string does not match {schema['pattern']!r}") + if "format" in schema and not format_matches(instance, schema["format"]): + errors.append(f"{at}: string is not a valid {schema['format']}") + + if isinstance(instance, (int, float)) and not isinstance(instance, bool): + if instance < schema.get("minimum", float("-inf")): + errors.append(f"{at}: value is below minimum") + if instance > schema.get("maximum", float("inf")): + errors.append(f"{at}: value is above maximum") + + if isinstance(instance, dict): + for name in schema.get("required", []): + if name not in instance: + errors.append(f"{at}: missing required property {name!r}") + if len(instance) < schema.get("minProperties", 0): + errors.append(f"{at}: object has fewer than minProperties") + if len(instance) > schema.get("maxProperties", sys.maxsize): + errors.append(f"{at}: object has more than maxProperties") + properties = schema.get("properties", {}) + for name, value in instance.items(): + if name in properties: + errors.extend(validate(value, properties[name], document_path, f"{at}.{name}")) + elif schema.get("additionalProperties") is False: + errors.append(f"{at}: unexpected property {name!r}") + + if isinstance(instance, list): + if len(instance) < schema.get("minItems", 0): + errors.append(f"{at}: array has fewer than minItems") + if len(instance) > schema.get("maxItems", sys.maxsize): + errors.append(f"{at}: array has more than maxItems") + if schema.get("uniqueItems"): + canonical = [json.dumps(item, sort_keys=True, separators=(",", ":")) for item in instance] + if len(canonical) != len(set(canonical)): + errors.append(f"{at}: array items are not unique") + if "items" in schema: + for index, value in enumerate(instance): + errors.extend(validate(value, schema["items"], document_path, f"{at}[{index}]")) + + return errors + + +def check_schema_set() -> list[str]: + errors: list[str] = [] + actual = {path.name for path in SCHEMA_DIR.glob("*.schema.json")} + if actual != SCHEMAS: + errors.append(f"schema file set drift: expected {sorted(SCHEMAS)}, found {sorted(actual)}") + identifiers: set[str] = set() + for name in sorted(SCHEMAS): + path = SCHEMA_DIR / name + schema = load_json(path) + if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + errors.append(f"{name}: must use JSON Schema Draft 2020-12") + identifier = schema.get("$id") + if identifier != name or identifier in identifiers: + errors.append(f"{name}: $id must be the unique relative schema filename") + identifiers.add(identifier) + if "Implementation Artifact / Non-normative" not in schema.get("$comment", ""): + errors.append(f"{name}: missing non-normative marker") + return errors + + +def check_examples() -> list[str]: + errors: list[str] = [] + actual = {path.name for path in EXAMPLE_DIR.glob("*.json")} + if actual != set(EXAMPLES): + errors.append(f"example file set drift: expected {sorted(EXAMPLES)}, found {sorted(actual)}") + for example_name, schema_name in EXAMPLES.items(): + example_path = EXAMPLE_DIR / example_name + schema_path = SCHEMA_DIR / schema_name + for error in validate(load_json(example_path), load_json(schema_path), schema_path): + errors.append(f"examples/{example_name}: {error}") + return errors + + +def check_openapi_references() -> list[str]: + errors: list[str] = [] + text = OPENAPI_PATH.read_text(encoding="utf-8") + if not text.startswith("openapi: 3.1.0\n"): + errors.append("openapi/openapi.yaml: must declare OpenAPI 3.1.0") + if "Non-normative local Sandbox binding" not in text: + errors.append("openapi/openapi.yaml: missing non-normative marker") + references = re.findall(r"(?:\$ref|externalValue):\s*[\"']?([^\s\"']+)", text) + for reference in references: + if reference.startswith("#"): + continue + target = reference.split("#", 1)[0] + if not (OPENAPI_PATH.parent / target).resolve().is_file(): + errors.append(f"openapi/openapi.yaml: unresolved local reference {reference}") + return errors + + +def check_vectors() -> list[str]: + errors: list[str] = [] + counts = {} + for kind in ("valid", "invalid"): + paths = sorted((VECTOR_DIR / kind).glob("*.json")) + counts[kind] = len(paths) + if not paths: + errors.append(f"test-vectors/{kind}: no vectors found") + for path in paths: + value = load_json(path) + if not isinstance(value, dict): + errors.append(f"test-vectors/{kind}/{path.name}: vector must be an object") + continue + if "expectedValid" in value and value["expectedValid"] is not (kind == "valid"): + errors.append(f"test-vectors/{kind}/{path.name}: expectedValid disagrees with directory") + return errors + + +def main() -> int: + errors = check_schema_set() + check_examples() + check_openapi_references() + check_vectors() + if errors: + print("TSD-CRD implementation artifact validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + valid_count = len(list((VECTOR_DIR / "valid").glob("*.json"))) + invalid_count = len(list((VECTOR_DIR / "invalid").glob("*.json"))) + print( + f"TSD-CRD artifacts passed: {len(SCHEMAS)} schemas, {len(EXAMPLES)} examples, " + f"OpenAPI references, {valid_count} valid vectors, and {invalid_count} invalid vectors." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify.sh b/tools/verify.sh index 754c1de..9860057 100755 --- a/tools/verify.sh +++ b/tools/verify.sh @@ -4,28 +4,34 @@ set -eu ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$ROOT" -echo "[1/8] Repository integrity" +echo "[1/10] Repository integrity" python3 tools/quality/check_repository.py -echo "[2/8] Public release snapshot" +echo "[2/10] Public release snapshot" python3 tools/quality/create_public_snapshot.py --check -echo "[3/8] A402 implementation artifacts" +echo "[3/10] A402 implementation artifacts" python3 tools/a402/validate_contract.py -echo "[4/8] Product-neutral A402 sample" +echo "[4/10] TSD-CRD implementation artifacts" +python3 tools/tsd-crd/validate_contract.py + +echo "[5/10] Product-neutral A402 sample" npm --prefix code/samples/local-a402 test -echo "[5/8] Alipay buyer integration" +echo "[6/10] TSD-CRD reference implementation" +npm --prefix code/samples/tsd-crd-reference run check + +echo "[7/10] Alipay buyer integration" npm --prefix integrations/alipay/buyer-agent test -echo "[6/8] Alipay seller integration" +echo "[8/10] Alipay seller integration" mvn -f integrations/alipay/seller-java/pom.xml test -echo "[7/8] Alipay validation tools" +echo "[9/10] Alipay validation tools" node --test integrations/alipay/validation/*.test.mjs -echo "[8/8] Machine payment showcase" +echo "[10/10] Machine payment showcase" npm --prefix code/web-client/alipay-ai-pay-showcase test npm --prefix code/web-client/alipay-ai-pay-showcase run build