From c901f6f3ade9a74a125d53b375f64eda285104f7 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 7 Apr 2026 16:53:28 +0100 Subject: [PATCH] refactor: centralize all KERI protocol logic into auths-keri MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auths-keri is now the single authoritative source for all KERI types, validation, witness protocol, and KEL storage traits. Every other crate imports from it — nothing re-implements KERI concepts in parallel --- .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 82 + .../gitnexus/gitnexus-debugging/SKILL.md | 89 ++ .../gitnexus/gitnexus-exploring/SKILL.md | 78 + .../skills/gitnexus/gitnexus-guide/SKILL.md | 64 + .../gitnexus-impact-analysis/SKILL.md | 97 ++ .../gitnexus/gitnexus-refactoring/SKILL.md | 121 ++ CHANGELOG.md | 4 + Cargo.lock | 17 +- Cargo.toml | 1 + crates/auths-cli/Cargo.toml | 1 + .../auths-cli/src/commands/artifact/verify.rs | 5 +- .../src/commands/device/verify_attestation.rs | 5 +- .../auths-cli/src/commands/verify_commit.rs | 5 +- crates/auths-cli/tests/cases/key_rotation.rs | 2 +- crates/auths-core/Cargo.toml | 1 + crates/auths-core/src/crypto/said.rs | 121 +- crates/auths-core/src/policy/org.rs | 4 +- crates/auths-core/src/ports/network.rs | 2 +- crates/auths-core/src/ports/storage/error.rs | 18 + .../src/ports/storage/event_log_reader.rs | 46 +- .../src/ports/storage/event_log_writer.rs | 34 +- .../src/testing/in_memory_storage.rs | 17 +- .../auths-core/src/witness/async_provider.rs | 260 +--- crates/auths-core/src/witness/collector.rs | 2 +- crates/auths-core/src/witness/duplicity.rs | 4 +- crates/auths-core/src/witness/error.rs | 203 +-- crates/auths-core/src/witness/hash.rs | 308 +--- crates/auths-core/src/witness/mod.rs | 14 +- crates/auths-core/src/witness/noop.rs | 4 +- crates/auths-core/src/witness/provider.rs | 133 +- crates/auths-core/src/witness/receipt.rs | 383 +---- crates/auths-core/src/witness/server.rs | 2 +- crates/auths-core/src/witness/storage.rs | 2 +- crates/auths-core/tests/cases/mod.rs | 1 - .../tests/cases/said_cross_validation.rs | 85 -- crates/auths-crypto/src/lib.rs | 3 - crates/auths-id/Cargo.toml | 1 + crates/auths-id/src/domain/keri_resolve.rs | 2 +- crates/auths-id/src/identity/resolve.rs | 2 +- crates/auths-id/src/keri/event.rs | 574 +------ crates/auths-id/src/keri/mod.rs | 4 +- crates/auths-id/src/keri/resolve.rs | 2 +- crates/auths-id/src/keri/seal.rs | 154 +- crates/auths-id/src/keri/state.rs | 223 +-- crates/auths-id/src/keri/types.rs | 2 +- crates/auths-id/src/keri/validate.rs | 1350 +---------------- crates/auths-id/src/policy/mod.rs | 2 +- crates/auths-id/src/storage/receipts.rs | 2 +- crates/auths-id/src/testing/fakes/registry.rs | 2 +- crates/auths-id/src/trailer.rs | 2 +- crates/auths-id/src/trust/mod.rs | 4 +- crates/auths-index/Cargo.toml | 1 + crates/auths-index/src/index.rs | 2 +- crates/auths-infra-git/Cargo.toml | 1 + crates/auths-infra-git/src/event_log.rs | 75 +- .../auths-infra-git/tests/cases/event_log.rs | 7 +- crates/auths-infra-http/Cargo.toml | 1 + .../src/async_witness_client.rs | 2 +- crates/auths-infra-http/src/witness_client.rs | 2 +- .../auths-infra-http/tests/cases/witness.rs | 2 +- crates/auths-keri/Cargo.toml | 15 +- crates/auths-keri/fuzz/Cargo.toml | 1 + crates/auths-keri/src/crypto.rs | 151 ++ crates/auths-keri/src/event.rs | 99 +- crates/auths-keri/src/events.rs | 557 +++++++ crates/auths-keri/src/kel_io.rs | 127 ++ .../src/keri.rs => auths-keri/src/keys.rs} | 10 +- crates/auths-keri/src/lib.rs | 62 +- crates/auths-keri/src/roundtrip.rs | 17 +- crates/auths-keri/src/said.rs | 55 +- crates/auths-keri/src/state.rs | 222 +++ crates/auths-keri/src/types.rs | 229 +++ crates/auths-keri/src/validate.rs | 777 ++++++++++ .../auths-keri/src/witness/async_provider.rs | 253 +++ crates/auths-keri/src/witness/error.rs | 202 +++ crates/auths-keri/src/witness/hash.rs | 307 ++++ crates/auths-keri/src/witness/mod.rs | 11 + crates/auths-keri/src/witness/provider.rs | 119 ++ crates/auths-keri/src/witness/receipt.rs | 336 ++++ crates/auths-keri/tests/cases/event.rs | 84 +- crates/auths-keri/tests/cases/mod.rs | 4 + crates/auths-keri/tests/cases/roundtrip.rs | 143 +- crates/auths-keri/tests/cases/stream.rs | 111 +- crates/auths-radicle/Cargo.toml | 1 + crates/auths-radicle/src/identity.rs | 4 +- crates/auths-radicle/src/verify.rs | 2 +- crates/auths-radicle/tests/cases/helpers.rs | 2 +- crates/auths-sdk/Cargo.toml | 1 + .../src/domains/identity/registration.rs | 4 +- .../src/domains/identity/rotation.rs | 2 +- crates/auths-sdk/src/workflows/rotation.rs | 2 +- crates/auths-storage/Cargo.toml | 1 + crates/auths-storage/src/git/adapter.rs | 20 +- .../auths-storage/src/git/identity_adapter.rs | 12 +- crates/auths-storage/src/postgres/adapter.rs | 2 +- crates/auths-verifier/Cargo.toml | 3 +- crates/auths-verifier/src/ffi.rs | 9 +- crates/auths-verifier/src/keri.rs | 1329 ---------------- crates/auths-verifier/src/lib.rs | 13 +- crates/auths-verifier/src/types.rs | 2 +- crates/auths-verifier/src/verify.rs | 35 +- crates/auths-verifier/src/wasm.rs | 19 +- crates/auths-verifier/src/witness.rs | 76 +- .../tests/cases/kel_verification.rs | 88 +- crates/auths-verifier/tests/cases/newtypes.rs | 6 +- .../tests/cases/serialization_pinning.rs | 52 +- crates/xtask/src/schemas.rs | 2 +- packages/auths-node/src/verify.rs | 4 +- packages/auths-python/Cargo.lock | 25 +- packages/auths-python/src/verify.rs | 4 +- 110 files changed, 4490 insertions(+), 5759 deletions(-) create mode 100644 .claude/skills/gitnexus/gitnexus-cli/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-debugging/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-exploring/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-guide/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md delete mode 100644 crates/auths-core/tests/cases/said_cross_validation.rs create mode 100644 crates/auths-keri/src/crypto.rs create mode 100644 crates/auths-keri/src/events.rs create mode 100644 crates/auths-keri/src/kel_io.rs rename crates/{auths-crypto/src/keri.rs => auths-keri/src/keys.rs} (92%) create mode 100644 crates/auths-keri/src/state.rs create mode 100644 crates/auths-keri/src/types.rs create mode 100644 crates/auths-keri/src/validate.rs create mode 100644 crates/auths-keri/src/witness/async_provider.rs create mode 100644 crates/auths-keri/src/witness/error.rs create mode 100644 crates/auths-keri/src/witness/hash.rs create mode 100644 crates/auths-keri/src/witness/mod.rs create mode 100644 crates/auths-keri/src/witness/provider.rs create mode 100644 crates/auths-keri/src/witness/receipt.rs delete mode 100644 crates/auths-verifier/src/keri.rs diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 00000000..c9e0af34 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,82 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +All commands work via `npx` — no global install required. + +## Commands + +### analyze — Build or refresh the index + +```bash +npx gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. + +### status — Check index freshness + +```bash +npx gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +npx gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +npx gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +npx gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 00000000..9510b97a --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] gitnexus_query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code related to error: + +``` +gitnexus_query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**gitnexus_context** — full context for a suspect: + +``` +gitnexus_context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. gitnexus_query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. gitnexus_context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 00000000..927a4e4b --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. gitnexus_query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. gitnexus_context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 00000000..937ac73d --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 00000000..e19af280 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. gitnexus_impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 00000000..f48cc01b --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | gitnexus_query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. gitnexus_detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db3cacc..62732ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- **xtask:** Removed `cargo xt ci-setup`. Use `auths ci setup` (or `just ci-setup`) instead. + ### Added (Unified Python SDK) - **`auths-python`: Unified Python SDK package** — consolidated `auths-verifier-python` and `auths-agent-python` into a single `packages/auths-python` crate. Shared FFI runtime, module registration, and type definitions in `src/runtime.rs` and `src/types.rs`. diff --git a/Cargo.lock b/Cargo.lock index a5cefbf6..5e2cbd9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ "auths-index", "auths-infra-git", "auths-infra-http", + "auths-keri", "auths-pairing-daemon", "auths-pairing-protocol", "auths-policy", @@ -404,6 +405,7 @@ dependencies = [ "assert_matches", "async-trait", "auths-crypto", + "auths-keri", "auths-pairing-protocol", "auths-verifier", "axum", @@ -482,6 +484,7 @@ dependencies = [ "auths-crypto", "auths-index", "auths-infra-http", + "auths-keri", "auths-policy", "auths-test-utils", "auths-utils", @@ -523,6 +526,7 @@ name = "auths-index" version = "0.1.0" dependencies = [ "anyhow", + "auths-keri", "auths-verifier", "chrono", "git2", @@ -539,6 +543,7 @@ name = "auths-infra-git" version = "0.1.0" dependencies = [ "auths-core", + "auths-keri", "auths-sdk", "auths-test-utils", "auths-verifier", @@ -556,6 +561,7 @@ dependencies = [ "async-trait", "auths-core", "auths-crypto", + "auths-keri", "auths-oidc-port", "auths-verifier", "axum", @@ -589,17 +595,20 @@ dependencies = [ name = "auths-keri" version = "0.1.0" dependencies = [ + "async-trait", "auths-crypto", - "auths-verifier", "base64", "blake3", "cesride", "hex", "proptest", + "ring", "schemars 0.8.22", "serde", "serde_json", + "subtle", "thiserror 2.0.18", + "tokio", ] [[package]] @@ -699,6 +708,7 @@ version = "0.1.0" dependencies = [ "auths-crypto", "auths-id", + "auths-keri", "auths-verifier", "bs58", "chrono", @@ -734,6 +744,7 @@ dependencies = [ "auths-crypto", "auths-id", "auths-infra-http", + "auths-keri", "auths-oidc-port", "auths-pairing-daemon", "auths-policy", @@ -776,6 +787,7 @@ dependencies = [ "auths-crypto", "auths-id", "auths-index", + "auths-keri", "auths-verifier", "base64", "chrono", @@ -860,8 +872,8 @@ version = "0.1.0" dependencies = [ "async-trait", "auths-crypto", + "auths-keri", "base64", - "blake3", "bs58", "chrono", "getrandom 0.2.17", @@ -877,7 +889,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "subtle", "thiserror 2.0.18", "tokio", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 77323205..cdefe32a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ zeroize = { version = "1.8.1", features = ["serde", "derive"] } json-canon = "=0.1.3" auths-core = { path = "crates/auths-core", version = "0.1.0" } +auths-keri = { path = "crates/auths-keri", version = "0.1.0" } auths-id = { path = "crates/auths-id", version = "0.1.0" } auths-verifier = { path = "crates/auths-verifier", version = "0.1.0", default-features = false } auths-policy = { path = "crates/auths-policy", version = "0.1.0" } diff --git a/crates/auths-cli/Cargo.toml b/crates/auths-cli/Cargo.toml index e549e62c..8cdfbd40 100644 --- a/crates/auths-cli/Cargo.toml +++ b/crates/auths-cli/Cargo.toml @@ -24,6 +24,7 @@ name = "auths-verify" path = "src/bin/verify.rs" [dependencies] +auths-keri.workspace = true clap = { version = "4", features = ["derive"] } clap_complete = "4" colored = "3.1.1" diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index 6996d47e..baac7fd3 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -3,12 +3,13 @@ use serde::Serialize; use std::fs; use std::path::{Path, PathBuf}; +use auths_keri::witness::Receipt; use auths_transparency::{ BundleVerificationReport, CheckpointStatus, DelegationStatus, InclusionStatus, NamespaceStatus, OfflineBundle, SignatureStatus, TrustRoot, WitnessStatus, }; use auths_verifier::core::Attestation; -use auths_verifier::witness::{WitnessQuorum, WitnessReceipt, WitnessVerifyConfig}; +use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig}; use auths_verifier::{ CanonicalDid, Capability, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_capability, verify_chain_with_witnesses, @@ -343,7 +344,7 @@ async fn verify_witnesses( let receipts_bytes = fs::read(receipts_path) .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?; - let receipts: Vec = + let receipts: Vec = serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?; let witness_keys = parse_witness_keys(witness_keys_raw)?; diff --git a/crates/auths-cli/src/commands/device/verify_attestation.rs b/crates/auths-cli/src/commands/device/verify_attestation.rs index abbef960..737b506c 100644 --- a/crates/auths-cli/src/commands/device/verify_attestation.rs +++ b/crates/auths-cli/src/commands/device/verify_attestation.rs @@ -1,12 +1,13 @@ use crate::ux::format::is_json_mode; use anyhow::{Context, Result, anyhow}; +use auths_keri::witness::Receipt; use auths_sdk::trust::{PinnedIdentity, PinnedIdentityStore, RootsFile, TrustLevel, TrustPolicy}; use auths_verifier::Capability; use auths_verifier::core::Attestation; use auths_verifier::verify::{ verify_chain_with_witnesses, verify_with_capability, verify_with_keys, }; -use auths_verifier::witness::{WitnessReceipt, WitnessVerifyConfig}; +use auths_verifier::witness::WitnessVerifyConfig; use chrono::Utc; use clap::{Parser, ValueEnum}; use serde::Serialize; @@ -312,7 +313,7 @@ async fn run_verify(now: chrono::DateTime, cmd: &VerifyCommand) -> Result = serde_json::from_slice(&receipts_bytes) + let receipts: Vec = serde_json::from_slice(&receipts_bytes) .context("Failed to parse witness receipts JSON")?; let witness_keys = parse_witness_keys(&cmd.witness_keys)?; diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index 56276d9d..2def7e27 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -1,6 +1,7 @@ use crate::ux::format::is_json_mode; use anyhow::{Context, Result, anyhow}; -use auths_verifier::witness::{WitnessQuorum, WitnessReceipt, WitnessVerifyConfig}; +use auths_keri::witness::Receipt; +use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig}; use auths_verifier::{ Attestation, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses, }; @@ -501,7 +502,7 @@ async fn verify_witnesses( let receipts_bytes = fs::read(receipts_path) .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?; - let receipts: Vec = + let receipts: Vec = serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?; let witness_keys = parse_witness_keys(&cmd.witness_keys)?; diff --git a/crates/auths-cli/tests/cases/key_rotation.rs b/crates/auths-cli/tests/cases/key_rotation.rs index 7d9b5f63..843d9a2c 100644 --- a/crates/auths-cli/tests/cases/key_rotation.rs +++ b/crates/auths-cli/tests/cases/key_rotation.rs @@ -1,8 +1,8 @@ use tempfile::tempdir; +use auths_keri::Prefix; use auths_sdk::identity::KeyRotationEvent; use auths_sdk::keri::KeriGitStorage; -use auths_verifier::keri::Prefix; use chrono::Utc; use sha2::{Digest, Sha256}; diff --git a/crates/auths-core/Cargo.toml b/crates/auths-core/Cargo.toml index d03a8a5b..0047daab 100644 --- a/crates/auths-core/Cargo.toml +++ b/crates/auths-core/Cargo.toml @@ -52,6 +52,7 @@ schemars.workspace = true x25519-dalek = { version = "2", features = ["static_secrets"] } auths-verifier = { workspace = true, features = ["native"] } +auths-keri = { workspace = true } url = { version = "2", features = ["serde"] } uuid.workspace = true diff --git a/crates/auths-core/src/crypto/said.rs b/crates/auths-core/src/crypto/said.rs index 4847fc73..666f4922 100644 --- a/crates/auths-core/src/crypto/said.rs +++ b/crates/auths-core/src/crypto/said.rs @@ -1,121 +1,6 @@ //! SAID (Self-Addressing Identifier) computation for KERI. //! -//! This module provides functions for computing SAIDs and next-key commitments -//! as specified by KERI (Key Event Receipt Infrastructure). -//! -//! SAIDs use Blake3 hashing with Base64url encoding and an 'E' prefix -//! (derivation code for Blake3-256). - -use auths_verifier::keri::Said; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use subtle::ConstantTimeEq; - -/// Compute SAID (Self-Addressing Identifier) for a KERI event. -/// -// SYNC: must match auths-verifier/src/keri.rs — tested by said_cross_validation -/// The SAID is computed by: -/// 1. Hashing the input with Blake3 -/// 2. Encoding the hash as Base64url (no padding) -/// 3. Prefixing with 'E' (KERI derivation code for Blake3-256) -/// -/// # Arguments -/// * `event_json` - The canonical JSON bytes of the event (with 'd' field empty) -/// -/// # Returns -/// A `Said` wrapping a string like "EXq5YqaL6L48pf0fu7IUhL0JRaU2_RxFP0AL43wYn148" -pub fn compute_said(event_json: &[u8]) -> Said { - let hash = blake3::hash(event_json); - let encoded = URL_SAFE_NO_PAD.encode(hash.as_bytes()); - Said::new_unchecked(format!("E{}", encoded)) -} - -/// Compute next-key commitment hash for pre-rotation. -/// -// SYNC: must match auths-verifier/src/keri.rs — tested by said_cross_validation -/// The commitment is computed by: -/// 1. Hashing the public key bytes with Blake3 -/// 2. Encoding the hash as Base64url (no padding) -/// 3. Prefixing with 'E' (KERI derivation code for Blake3-256) -/// -/// This commitment is included in the current event's 'n' field and must -/// be satisfied by the next rotation event's 'k' field. -/// -/// # Arguments -/// * `public_key` - The raw public key bytes (32 bytes for Ed25519) -/// -/// # Returns -/// A commitment string like "EO8CE5RH3wHBrXyFay3MOXq5YqaL6L48pf0fu7IUhL0J" -pub fn compute_next_commitment(public_key: &[u8]) -> String { - let hash = blake3::hash(public_key); - let encoded = URL_SAFE_NO_PAD.encode(hash.as_bytes()); - format!("E{}", encoded) -} - -/// Verify that a public key matches a commitment. -/// -/// # Arguments -/// * `public_key` - The raw public key bytes to verify -/// * `commitment` - The commitment string from a previous event's 'n' field -/// -/// # Returns -/// `true` if the public key hashes to the commitment, `false` otherwise -// Defense-in-depth: both values are derived from public data, but constant-time -// comparison prevents timing side-channels on commitment verification. -pub fn verify_commitment(public_key: &[u8], commitment: &str) -> bool { - let computed = compute_next_commitment(public_key); - computed.as_bytes().ct_eq(commitment.as_bytes()).into() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn said_is_deterministic() { - let json = b"{\"t\":\"icp\",\"s\":\"0\"}"; - let said1 = compute_said(json); - let said2 = compute_said(json); - assert_eq!(said1, said2); - assert!(said1.as_str().starts_with('E')); - } - - #[test] - fn said_has_correct_length() { - let json = b"{\"test\":\"data\"}"; - let said = compute_said(json); - // 'E' + 43 chars of base64url (32 bytes encoded) - assert_eq!(said.as_str().len(), 44); - } - - #[test] - fn different_inputs_produce_different_saids() { - let said1 = compute_said(b"{\"a\":1}"); - let said2 = compute_said(b"{\"a\":2}"); - assert_ne!(said1, said2); - } - - #[test] - fn commitment_verification_works() { - let key = [1u8; 32]; - let commitment = compute_next_commitment(&key); - assert!(verify_commitment(&key, &commitment)); - assert!(!verify_commitment(&[2u8; 32], &commitment)); - } - - #[test] - fn commitment_is_deterministic() { - let key = [42u8; 32]; - let c1 = compute_next_commitment(&key); - let c2 = compute_next_commitment(&key); - assert_eq!(c1, c2); - assert!(c1.starts_with('E')); - } +//! Delegates to `auths-keri` — the single authoritative implementation. +//! This module exists for backwards-compatibility; it will be removed in Task 8. - #[test] - fn commitment_has_correct_length() { - let key = [0u8; 32]; - let commitment = compute_next_commitment(&key); - // 'E' + 43 chars of base64url - assert_eq!(commitment.len(), 44); - } -} +pub use auths_keri::{compute_next_commitment, compute_said, verify_commitment}; diff --git a/crates/auths-core/src/policy/org.rs b/crates/auths-core/src/policy/org.rs index 8645d589..80a5328f 100644 --- a/crates/auths-core/src/policy/org.rs +++ b/crates/auths-core/src/policy/org.rs @@ -18,8 +18,8 @@ //! with filtering (role, capabilities), use the registry's `list_org_members()` //! with `MemberFilter`, then apply this policy to each result. +use auths_keri::Prefix; use auths_verifier::core::{Attestation, Capability}; -use auths_verifier::keri::Prefix; use chrono::{DateTime, Utc}; use super::Decision; @@ -143,7 +143,7 @@ pub fn authorize_org_action( /// /// ```rust /// use auths_core::policy::org::expected_org_issuer; -/// use auths_verifier::keri::Prefix; +/// use auths_keri::Prefix; /// /// let prefix = Prefix::new_unchecked("EOrg12345".into()); /// assert_eq!(expected_org_issuer(&prefix), "did:keri:EOrg12345"); diff --git a/crates/auths-core/src/ports/network.rs b/crates/auths-core/src/ports/network.rs index bf96c4d2..01815877 100644 --- a/crates/auths-core/src/ports/network.rs +++ b/crates/auths-core/src/ports/network.rs @@ -2,8 +2,8 @@ use std::future::Future; +use auths_keri::Prefix; use auths_verifier::core::Ed25519PublicKey; -use auths_verifier::keri::Prefix; /// Domain error for outbound network operations. /// diff --git a/crates/auths-core/src/ports/storage/error.rs b/crates/auths-core/src/ports/storage/error.rs index 5f0f7473..f0d7bfc3 100644 --- a/crates/auths-core/src/ports/storage/error.rs +++ b/crates/auths-core/src/ports/storage/error.rs @@ -72,6 +72,24 @@ impl auths_crypto::AuthsErrorInfo for StorageError { } } +impl From for StorageError { + fn from(err: auths_keri::kel_io::KelStorageError) -> Self { + match err { + auths_keri::kel_io::KelStorageError::NotFound { path } => { + StorageError::NotFound { path } + } + auths_keri::kel_io::KelStorageError::AlreadyExists { path } => { + StorageError::AlreadyExists { path } + } + auths_keri::kel_io::KelStorageError::CasConflict => StorageError::CasConflict, + auths_keri::kel_io::KelStorageError::Io(s) => StorageError::Io(s), + auths_keri::kel_io::KelStorageError::Internal(e) => StorageError::Internal(e), + // #[non_exhaustive]: forward any future variants as internal errors + other => StorageError::Internal(Box::new(other)), + } + } +} + impl StorageError { /// Convenience constructor for `NotFound`. pub fn not_found(path: impl fmt::Display) -> Self { diff --git a/crates/auths-core/src/ports/storage/event_log_reader.rs b/crates/auths-core/src/ports/storage/event_log_reader.rs index decdda5b..5d90bdef 100644 --- a/crates/auths-core/src/ports/storage/event_log_reader.rs +++ b/crates/auths-core/src/ports/storage/event_log_reader.rs @@ -1,45 +1 @@ -use auths_verifier::keri::Prefix; - -use super::StorageError; - -/// Reads serialized key event log (KEL) entries for a KERI prefix. -/// -/// Implementations provide access to the ordered event history without -/// exposing how or where events are stored. -/// -/// Usage: -/// ```ignore -/// use auths_core::ports::storage::EventLogReader; -/// use auths_verifier::keri::Prefix; -/// -/// fn latest_event(reader: &dyn EventLogReader, prefix: &Prefix) -> Vec { -/// let full_log = reader.read_event_log(prefix).unwrap(); -/// full_log -/// } -/// ``` -pub trait EventLogReader: Send + Sync { - /// Returns the complete serialized event log for the given KERI prefix. - /// - /// Args: - /// * `prefix`: The KERI prefix identifying the event log to read. - /// - /// Usage: - /// ```ignore - /// let prefix = Prefix::new_unchecked("EAbcdef...".into()); - /// let log_bytes = reader.read_event_log(&prefix)?; - /// ``` - fn read_event_log(&self, prefix: &Prefix) -> Result, StorageError>; - - /// Returns a single serialized event at the given sequence number. - /// - /// Args: - /// * `prefix`: The KERI prefix identifying the event log. - /// * `seq`: The zero-based sequence number of the event to retrieve. - /// - /// Usage: - /// ```ignore - /// let prefix = Prefix::new_unchecked("EAbcdef...".into()); - /// let inception = reader.read_event_at(&prefix, 0)?; - /// ``` - fn read_event_at(&self, prefix: &Prefix, seq: u64) -> Result, StorageError>; -} +pub use auths_keri::kel_io::EventLogReader; diff --git a/crates/auths-core/src/ports/storage/event_log_writer.rs b/crates/auths-core/src/ports/storage/event_log_writer.rs index f67a6a39..60812f0b 100644 --- a/crates/auths-core/src/ports/storage/event_log_writer.rs +++ b/crates/auths-core/src/ports/storage/event_log_writer.rs @@ -1,33 +1 @@ -use auths_verifier::keri::Prefix; - -use super::StorageError; - -/// Appends serialized key events to a KERI prefix's event log. -/// -/// Implementations handle the mechanics of persisting a new event -/// (e.g., writing a Git commit, inserting a database row) while the -/// domain only provides the serialized event bytes. -/// -/// Usage: -/// ```ignore -/// use auths_core::ports::storage::EventLogWriter; -/// use auths_verifier::keri::Prefix; -/// -/// fn record_inception(writer: &dyn EventLogWriter, prefix: &Prefix, event: &[u8]) { -/// writer.append_event(prefix, event).unwrap(); -/// } -/// ``` -pub trait EventLogWriter: Send + Sync { - /// Appends a serialized event to the log for the given KERI prefix. - /// - /// Args: - /// * `prefix`: The KERI prefix identifying the event log. - /// * `event`: The serialized event bytes to append. - /// - /// Usage: - /// ```ignore - /// let prefix = Prefix::new_unchecked("EAbcdef...".into()); - /// writer.append_event(&prefix, &serialized_icp)?; - /// ``` - fn append_event(&self, prefix: &Prefix, event: &[u8]) -> Result<(), StorageError>; -} +pub use auths_keri::kel_io::EventLogWriter; diff --git a/crates/auths-core/src/testing/in_memory_storage.rs b/crates/auths-core/src/testing/in_memory_storage.rs index babf7e6b..dfee65e5 100644 --- a/crates/auths-core/src/testing/in_memory_storage.rs +++ b/crates/auths-core/src/testing/in_memory_storage.rs @@ -1,7 +1,8 @@ use crate::ports::storage::{ BlobReader, BlobWriter, EventLogReader, EventLogWriter, RefReader, RefWriter, StorageError, }; -use auths_verifier::keri::Prefix; +use auths_keri::Prefix; +use auths_keri::kel_io::KelStorageError; use std::collections::HashMap; use std::sync::Mutex; @@ -114,7 +115,7 @@ impl RefWriter for InMemoryStorage { } impl EventLogReader for InMemoryStorage { - fn read_event_log(&self, prefix: &Prefix) -> Result, StorageError> { + fn read_event_log(&self, prefix: &Prefix) -> Result, KelStorageError> { let store = self.event_logs.lock().unwrap(); match store.get(prefix.as_str()) { Some(events) => Ok(events.iter().flatten().cloned().collect()), @@ -122,19 +123,23 @@ impl EventLogReader for InMemoryStorage { } } - fn read_event_at(&self, prefix: &Prefix, seq: u64) -> Result, StorageError> { + fn read_event_at(&self, prefix: &Prefix, seq: u64) -> Result, KelStorageError> { let store = self.event_logs.lock().unwrap(); let key = prefix.as_str(); - let events = store.get(key).ok_or_else(|| StorageError::not_found(key))?; + let events = store.get(key).ok_or_else(|| KelStorageError::NotFound { + path: key.to_string(), + })?; events .get(seq as usize) .cloned() - .ok_or_else(|| StorageError::not_found(format!("{}/seq/{}", key, seq))) + .ok_or_else(|| KelStorageError::NotFound { + path: format!("{}/seq/{}", key, seq), + }) } } impl EventLogWriter for InMemoryStorage { - fn append_event(&self, prefix: &Prefix, event: &[u8]) -> Result<(), StorageError> { + fn append_event(&self, prefix: &Prefix, event: &[u8]) -> Result<(), KelStorageError> { let mut store = self.event_logs.lock().unwrap(); store .entry(prefix.as_str().to_string()) diff --git a/crates/auths-core/src/witness/async_provider.rs b/crates/auths-core/src/witness/async_provider.rs index 42acd74f..3dbad4a6 100644 --- a/crates/auths-core/src/witness/async_provider.rs +++ b/crates/auths-core/src/witness/async_provider.rs @@ -1,259 +1 @@ -//! Async witness provider trait for network-based witness operations. -//! -//! This module defines the async version of the witness provider trait, -//! designed for network-based witness interactions that require async I/O. -//! -//! # Design Rationale -//! -//! The sync [`WitnessProvider`] trait is preserved for backward compatibility -//! and for use cases where blocking is acceptable (e.g., local caching). -//! This async trait is designed for: -//! -//! - HTTP-based witness servers -//! - Network I/O with configurable timeouts -//! - Parallel receipt collection from multiple witnesses -//! -//! # Example -//! -//! ```rust,ignore -//! use auths_core::witness::{AsyncWitnessProvider, Receipt, WitnessError, EventHash}; -//! use auths_verifier::keri::{Prefix, Said}; -//! use async_trait::async_trait; -//! -//! struct HttpWitness { -//! base_url: String, -//! timeout_ms: u64, -//! } -//! -//! #[async_trait] -//! impl AsyncWitnessProvider for HttpWitness { -//! async fn submit_event(&self, prefix: &Prefix, event_json: &[u8]) -> Result { -//! // HTTP POST to witness server -//! todo!() -//! } -//! -//! async fn observe_identity_head(&self, prefix: &Prefix) -> Result, WitnessError> { -//! // HTTP GET to witness server -//! todo!() -//! } -//! -//! async fn get_receipt(&self, prefix: &Prefix, event_said: &Said) -> Result, WitnessError> { -//! // HTTP GET to witness server -//! todo!() -//! } -//! } -//! ``` - -use async_trait::async_trait; -use auths_verifier::keri::{Prefix, Said}; - -use super::error::WitnessError; -use super::hash::EventHash; -use super::receipt::Receipt; - -/// Async witness provider for network-based witness operations. -/// -/// This trait defines the interface for interacting with witness servers -/// asynchronously. Implementations typically communicate over HTTP with -/// witness infrastructure. -/// -/// # Thread Safety -/// -/// Implementations must be `Send + Sync` to allow use in async contexts -/// across multiple tasks. -/// -/// # Error Handling -/// -/// All methods return `Result` to enable proper error -/// propagation and handling of network failures, timeouts, and security -/// violations (like duplicity detection). -#[async_trait] -pub trait AsyncWitnessProvider: Send + Sync { - /// Submit an event to the witness for receipting. - /// - /// The witness will: - /// 1. Parse and validate the event - /// 2. Check for duplicity (same prefix+seq with different SAID) - /// 3. If valid and not duplicate, sign and return a receipt - /// - /// # Arguments - /// - /// * `prefix` - The KERI prefix of the identity - /// * `event_json` - The canonicalized JSON bytes of the event - /// - /// # Returns - /// - /// * `Ok(Receipt)` - The witness accepted the event and issued a receipt - /// * `Err(WitnessError::Duplicity(_))` - Duplicity detected (split-view attack) - /// * `Err(WitnessError::Rejected { .. })` - Event rejected (invalid format, etc.) - /// * `Err(WitnessError::Network(_))` - Network error - /// * `Err(WitnessError::Timeout(_))` - Operation timed out - async fn submit_event( - &self, - prefix: &Prefix, - event_json: &[u8], - ) -> Result; - - /// Query the current observed head for an identity. - /// - /// Returns the hash of the most recent event the witness has observed - /// for the given identity prefix. - /// - /// # Arguments - /// - /// * `prefix` - The KERI prefix of the identity - /// - /// # Returns - /// - /// * `Ok(Some(hash))` - The witness has an observed head for this identity - /// * `Ok(None)` - The witness has not observed any events for this identity - /// * `Err(_)` - Error during query - async fn observe_identity_head( - &self, - prefix: &Prefix, - ) -> Result, WitnessError>; - - /// Retrieve a previously issued receipt. - /// - /// # Arguments - /// - /// * `prefix` - The KERI prefix of the identity - /// * `event_said` - The SAID of the event to get the receipt for - /// - /// # Returns - /// - /// * `Ok(Some(receipt))` - Receipt found - /// * `Ok(None)` - No receipt found for this event - /// * `Err(_)` - Error during retrieval - async fn get_receipt( - &self, - prefix: &Prefix, - event_said: &Said, - ) -> Result, WitnessError>; - - /// Get the minimum quorum required for consistency. - /// - /// When using a multi-witness setup, this specifies how many witnesses - /// must agree for the event to be considered properly witnessed. - /// - /// # Default - /// - /// Returns `1` (single witness is sufficient). - fn quorum(&self) -> usize { - 1 - } - - /// Get the timeout for operations in milliseconds. - /// - /// # Default - /// - /// Returns `5000` (5 seconds). - fn timeout_ms(&self) -> u64 { - 5000 - } - - /// Check if this provider is currently available. - /// - /// This can be used for health checks before attempting operations. - /// - /// # Default - /// - /// Returns `Ok(true)`. Implementations may override to perform actual - /// health checks. - async fn is_available(&self) -> Result { - Ok(true) - } -} - -/// A no-op async witness provider that always succeeds without doing anything. -/// -/// This is useful for testing or when witness functionality is disabled. -#[derive(Debug, Clone, Default)] -pub struct NoOpAsyncWitness; - -#[async_trait] -impl AsyncWitnessProvider for NoOpAsyncWitness { - async fn submit_event( - &self, - _prefix: &Prefix, - _event_json: &[u8], - ) -> Result { - // Return a dummy receipt - Ok(Receipt { - v: super::receipt::KERI_VERSION.into(), - t: super::receipt::RECEIPT_TYPE.into(), - d: Said::new_unchecked("ENoop".into()), - i: "did:key:noop".into(), - s: 0, - a: Said::new_unchecked("ENoop".into()), - sig: vec![0u8; 64], - }) - } - - async fn observe_identity_head( - &self, - _prefix: &Prefix, - ) -> Result, WitnessError> { - Ok(None) - } - - async fn get_receipt( - &self, - _prefix: &Prefix, - _event_said: &Said, - ) -> Result, WitnessError> { - Ok(None) - } - - fn quorum(&self) -> usize { - 0 // No quorum required for no-op - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn noop_witness_submit_returns_dummy_receipt() { - let witness = NoOpAsyncWitness; - let prefix = Prefix::new_unchecked("ETest".into()); - let receipt = witness.submit_event(&prefix, b"{}").await.unwrap(); - assert_eq!(receipt.t, "rct"); - } - - #[tokio::test] - async fn noop_witness_observe_returns_none() { - let witness = NoOpAsyncWitness; - let prefix = Prefix::new_unchecked("ETest".into()); - let head = witness.observe_identity_head(&prefix).await.unwrap(); - assert!(head.is_none()); - } - - #[tokio::test] - async fn noop_witness_get_receipt_returns_none() { - let witness = NoOpAsyncWitness; - let prefix = Prefix::new_unchecked("ETest".into()); - let said = Said::new_unchecked("ESAID".into()); - let receipt = witness.get_receipt(&prefix, &said).await.unwrap(); - assert!(receipt.is_none()); - } - - #[tokio::test] - async fn noop_witness_quorum_is_zero() { - let witness = NoOpAsyncWitness; - assert_eq!(witness.quorum(), 0); - } - - #[tokio::test] - async fn noop_witness_is_available() { - let witness = NoOpAsyncWitness; - assert!(witness.is_available().await.unwrap()); - } - - #[test] - fn default_timeout() { - let witness = NoOpAsyncWitness; - assert_eq!(witness.timeout_ms(), 5000); - } -} +pub use auths_keri::witness::AsyncWitnessProvider; diff --git a/crates/auths-core/src/witness/collector.rs b/crates/auths-core/src/witness/collector.rs index b26d3196..0459030c 100644 --- a/crates/auths-core/src/witness/collector.rs +++ b/crates/auths-core/src/witness/collector.rs @@ -22,7 +22,7 @@ use std::sync::Arc; -use auths_verifier::keri::Prefix; +use auths_keri::Prefix; use tokio::time::{Duration, timeout}; use super::async_provider::AsyncWitnessProvider; diff --git a/crates/auths-core/src/witness/duplicity.rs b/crates/auths-core/src/witness/duplicity.rs index 305933fc..1f525e93 100644 --- a/crates/auths-core/src/witness/duplicity.rs +++ b/crates/auths-core/src/witness/duplicity.rs @@ -18,7 +18,7 @@ use std::collections::HashMap; -use auths_verifier::keri::{Prefix, Said}; +use auths_keri::{Prefix, Said}; use super::error::{DuplicityEvidence, WitnessReport}; use super::receipt::Receipt; @@ -38,7 +38,7 @@ use super::receipt::Receipt; /// /// ```rust /// use auths_core::witness::DuplicityDetector; -/// use auths_verifier::keri::{Prefix, Said}; +/// use auths_keri::{Prefix, Said}; /// /// let mut detector = DuplicityDetector::new(); /// let prefix = Prefix::new_unchecked("EPrefix".into()); diff --git a/crates/auths-core/src/witness/error.rs b/crates/auths-core/src/witness/error.rs index 2a93adb5..f485edca 100644 --- a/crates/auths-core/src/witness/error.rs +++ b/crates/auths-core/src/witness/error.rs @@ -1,202 +1 @@ -//! Error types for witness operations. -//! -//! This module defines the error types used by the async witness infrastructure, -//! including duplicity evidence for split-view detection. - -use auths_verifier::keri::{Prefix, Said}; -use serde::{Deserialize, Serialize}; -use std::fmt; - -/// Evidence of duplicity (split-view attack) detected by witnesses. -/// -/// When a controller presents different events with the same (prefix, seq) -/// to different witnesses, this evidence captures the conflicting SAIDs. -/// -/// # Fields -/// -/// - `prefix`: The KERI prefix of the identity -/// - `sequence`: The sequence number where duplicity was detected -/// - `event_a_said`: SAID of the first event seen -/// - `event_b_said`: SAID of the conflicting event -/// - `witness_reports`: Reports from witnesses that observed the conflict -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DuplicityEvidence { - /// The KERI prefix of the identity - pub prefix: Prefix, - /// The sequence number where duplicity was detected - pub sequence: u64, - /// SAID of the first event seen (the "canonical" one) - pub event_a_said: Said, - /// SAID of the conflicting event - pub event_b_said: Said, - /// Reports from individual witnesses - pub witness_reports: Vec, -} - -impl fmt::Display for DuplicityEvidence { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Duplicity detected for {} at seq {}: {} vs {}", - self.prefix, self.sequence, self.event_a_said, self.event_b_said - ) - } -} - -/// A report from a single witness about what it observed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WitnessReport { - /// The witness identifier (DID) - pub witness_id: String, - /// The SAID this witness observed for the (prefix, seq) - pub observed_said: Said, - /// When this observation was made (ISO 8601) - pub observed_at: Option, -} - -/// Errors that can occur during witness operations. -/// -/// These errors cover the full range of failure modes for async witness -/// interactions, from network issues to security violations. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum WitnessError { - /// Network error communicating with witness. - #[error("network error: {0}")] - Network(String), - - /// Duplicity detected - the controller presented different events. - /// - /// This is a **security violation** indicating a potential split-view attack. - #[error("duplicity detected: {0}")] - Duplicity(DuplicityEvidence), - - /// The witness rejected the event. - /// - /// This can happen if the event is malformed, the witness doesn't track - /// this identity, or the event fails validation. - #[error("event rejected: {reason}")] - Rejected { - /// Human-readable reason for rejection - reason: String, - }, - - /// Operation timed out. - #[error("timeout after {0}ms")] - Timeout(u64), - - /// Invalid receipt signature. - #[error("invalid receipt signature from witness {witness_id}")] - InvalidSignature { - /// The witness that provided the invalid signature - witness_id: String, - }, - - /// Insufficient receipts to meet threshold. - #[error("insufficient receipts: got {got}, need {required}")] - InsufficientReceipts { - /// Number of receipts received - got: usize, - /// Number of receipts required - required: usize, - }, - - /// Receipt is for wrong event. - #[error("receipt SAID mismatch: expected {expected}, got {got}")] - SaidMismatch { - /// Expected event SAID - expected: Said, - /// Actual SAID in receipt - got: Said, - }, - - /// Storage error. - #[error("storage error: {0}")] - Storage(String), - - /// Serialization error. - #[error("serialization error: {0}")] - Serialization(String), -} - -impl auths_crypto::AuthsErrorInfo for WitnessError { - fn error_code(&self) -> &'static str { - match self { - Self::Network(_) => "AUTHS-E3401", - Self::Duplicity(_) => "AUTHS-E3402", - Self::Rejected { .. } => "AUTHS-E3403", - Self::Timeout(_) => "AUTHS-E3404", - Self::InvalidSignature { .. } => "AUTHS-E3405", - Self::InsufficientReceipts { .. } => "AUTHS-E3406", - Self::SaidMismatch { .. } => "AUTHS-E3407", - Self::Storage(_) => "AUTHS-E3408", - Self::Serialization(_) => "AUTHS-E3409", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::Duplicity(_) => { - Some("This identity may be compromised — investigate immediately") - } - Self::Timeout(_) => Some("Check witness endpoint availability and retry"), - Self::InsufficientReceipts { .. } => Some("Ensure enough witnesses are online"), - Self::Network(_) => Some("Check your internet connection"), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn duplicity_evidence_display() { - let evidence = DuplicityEvidence { - prefix: Prefix::new_unchecked("EPrefix123".into()), - sequence: 5, - event_a_said: Said::new_unchecked("ESAID_A".into()), - event_b_said: Said::new_unchecked("ESAID_B".into()), - witness_reports: vec![], - }; - let display = format!("{}", evidence); - assert!(display.contains("EPrefix123")); - assert!(display.contains("5")); - assert!(display.contains("ESAID_A")); - assert!(display.contains("ESAID_B")); - } - - #[test] - fn witness_error_variants() { - let network_err = WitnessError::Network("connection refused".into()); - assert!(format!("{}", network_err).contains("network error")); - - let timeout_err = WitnessError::Timeout(5000); - assert!(format!("{}", timeout_err).contains("5000ms")); - - let rejected_err = WitnessError::Rejected { - reason: "invalid format".into(), - }; - assert!(format!("{}", rejected_err).contains("invalid format")); - } - - #[test] - fn duplicity_evidence_serialization() { - let evidence = DuplicityEvidence { - prefix: Prefix::new_unchecked("EPrefix123".into()), - sequence: 5, - event_a_said: Said::new_unchecked("ESAID_A".into()), - event_b_said: Said::new_unchecked("ESAID_B".into()), - witness_reports: vec![WitnessReport { - witness_id: "did:key:witness1".into(), - observed_said: Said::new_unchecked("ESAID_A".into()), - observed_at: Some("2024-01-01T00:00:00Z".into()), - }], - }; - - let json = serde_json::to_string(&evidence).unwrap(); - let parsed: DuplicityEvidence = serde_json::from_str(&json).unwrap(); - assert_eq!(evidence, parsed); - } -} +pub use auths_keri::witness::{DuplicityEvidence, WitnessError, WitnessReport}; diff --git a/crates/auths-core/src/witness/hash.rs b/crates/auths-core/src/witness/hash.rs index 11aa1c51..33d9e7fa 100644 --- a/crates/auths-core/src/witness/hash.rs +++ b/crates/auths-core/src/witness/hash.rs @@ -1,307 +1 @@ -//! Backend-agnostic event hash type. -//! -//! This module provides [`EventHash`], a 20-byte hash type used to identify -//! KEL events without depending on any specific storage backend (e.g., git2). -//! -//! # Why 20 Bytes? -//! -//! Git uses SHA-1 (20 bytes) for object identifiers. This type is sized to -//! be compatible with Git OIDs while remaining backend-agnostic. - -use std::fmt; -use std::str::FromStr; - -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; - -/// A 20-byte hash identifying a KEL event. -/// -/// Serializes as a 40-character lowercase hex string, matching the encoding -/// used by `git2::Oid::to_string()`. This ensures JSON payloads, API schemas, -/// and cache files remain compatible when migrating from `git2::Oid`. -/// -/// # Args -/// -/// The inner `[u8; 20]` represents the raw SHA-1 bytes. -/// -/// # Usage -/// -/// ```rust -/// use auths_core::witness::EventHash; -/// -/// // From raw bytes -/// let bytes = [0u8; 20]; -/// let hash = EventHash::from_bytes(bytes); -/// assert_eq!(hash.as_bytes(), &bytes); -/// -/// // From hex string -/// let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); -/// assert_eq!(hash.to_hex(), "0000000000000000000000000000000000000001"); -/// -/// // Serde: serializes as hex string, not integer array -/// let json = serde_json::to_string(&hash).unwrap(); -/// assert_eq!(json, r#""0000000000000000000000000000000000000001""#); -/// ``` -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct EventHash([u8; 20]); - -impl EventHash { - /// Create an EventHash from raw bytes. - #[inline] - pub const fn from_bytes(bytes: [u8; 20]) -> Self { - Self(bytes) - } - - /// Get the raw bytes of this hash. - #[inline] - pub fn as_bytes(&self) -> &[u8; 20] { - &self.0 - } - - /// Create an EventHash from a hex string. - /// - /// Returns `None` if the string is not exactly 40 hex characters. - /// - /// # Example - /// - /// ```rust - /// use auths_core::witness::EventHash; - /// - /// let hash = EventHash::from_hex("0123456789abcdef0123456789abcdef01234567"); - /// assert!(hash.is_some()); - /// - /// // Wrong length - /// assert!(EventHash::from_hex("0123").is_none()); - /// - /// // Invalid characters - /// assert!(EventHash::from_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none()); - /// ``` - pub fn from_hex(s: &str) -> Option { - if s.len() != 40 { - return None; - } - - let mut bytes = [0u8; 20]; - for (i, chunk) in s.as_bytes().chunks(2).enumerate() { - let hi = hex_digit(chunk[0])?; - let lo = hex_digit(chunk[1])?; - bytes[i] = (hi << 4) | lo; - } - - Some(Self(bytes)) - } - - /// Convert this hash to a lowercase hex string. - /// - /// # Example - /// - /// ```rust - /// use auths_core::witness::EventHash; - /// - /// let hash = EventHash::from_bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, - /// 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); - /// assert_eq!(hash.to_hex(), "000102030405060708090a0b0c0d0e0f10111213"); - /// ``` - pub fn to_hex(&self) -> String { - let mut s = String::with_capacity(40); - for byte in &self.0 { - s.push(HEX_CHARS[(byte >> 4) as usize]); - s.push(HEX_CHARS[(byte & 0xf) as usize]); - } - s - } -} - -/// Hex characters for encoding. -const HEX_CHARS: [char; 16] = [ - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', -]; - -/// Convert a hex character to its numeric value. -#[inline] -fn hex_digit(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } -} - -impl fmt::Debug for EventHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "EventHash({})", self.to_hex()) - } -} - -impl fmt::Display for EventHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.to_hex()) - } -} - -/// Error returned when parsing an `EventHash` from a hex string fails. -/// -/// # Args -/// -/// * `InvalidLength` — the input was not exactly 40 hex characters -/// * `InvalidChar` — the input contained a non-hex character -/// -/// # Usage -/// -/// ```rust -/// use auths_core::witness::EventHash; -/// use std::str::FromStr; -/// -/// assert!(EventHash::from_str("not-hex").is_err()); -/// ``` -#[derive(Debug, thiserror::Error, PartialEq)] -#[non_exhaustive] -pub enum EventHashParseError { - /// The input string was not exactly 40 hex characters. - #[error("expected 40 hex characters, got {0}")] - InvalidLength(usize), - /// The input contained a non-hex character at the given position. - #[error("invalid hex character at position {position}: {ch:?}")] - InvalidChar { - /// Zero-based index of the first invalid character. - position: usize, - /// The character that failed hex decoding. - ch: char, - }, -} - -impl FromStr for EventHash { - type Err = EventHashParseError; - - fn from_str(s: &str) -> Result { - if s.len() != 40 { - return Err(EventHashParseError::InvalidLength(s.len())); - } - let mut bytes = [0u8; 20]; - for (i, chunk) in s.as_bytes().chunks(2).enumerate() { - let hi = hex_digit(chunk[0]).ok_or(EventHashParseError::InvalidChar { - position: i * 2, - ch: chunk[0] as char, - })?; - let lo = hex_digit(chunk[1]).ok_or(EventHashParseError::InvalidChar { - position: i * 2 + 1, - ch: chunk[1] as char, - })?; - bytes[i] = (hi << 4) | lo; - } - Ok(Self(bytes)) - } -} - -impl Serialize for EventHash { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.to_hex()) - } -} - -impl<'de> Deserialize<'de> for EventHash { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - s.parse::().map_err(de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn from_bytes_roundtrip() { - let bytes = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, - ]; - let hash = EventHash::from_bytes(bytes); - assert_eq!(hash.as_bytes(), &bytes); - } - - #[test] - fn from_hex_valid() { - let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); - let mut expected = [0u8; 20]; - expected[19] = 1; - assert_eq!(hash.as_bytes(), &expected); - } - - #[test] - fn from_hex_all_zeros() { - let hash = EventHash::from_hex("0000000000000000000000000000000000000000").unwrap(); - assert_eq!(hash.as_bytes(), &[0u8; 20]); - } - - #[test] - fn from_hex_uppercase() { - let hash = EventHash::from_hex("ABCDEF0123456789ABCDEF0123456789ABCDEF01").unwrap(); - assert!( - hash.to_hex() - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) - ); - } - - #[test] - fn from_hex_wrong_length() { - assert!(EventHash::from_hex("0123").is_none()); - assert!(EventHash::from_hex("").is_none()); - assert!(EventHash::from_hex("00000000000000000000000000000000000000001").is_none()); - } - - #[test] - fn from_hex_invalid_chars() { - assert!(EventHash::from_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none()); - assert!(EventHash::from_hex("0000000000000000000000000000000000000g01").is_none()); - } - - #[test] - fn to_hex_roundtrip() { - let original = "0123456789abcdef0123456789abcdef01234567"; - let hash = EventHash::from_hex(original).unwrap(); - assert_eq!(hash.to_hex(), original); - } - - #[test] - fn debug_format() { - let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); - let debug = format!("{:?}", hash); - assert!(debug.contains("EventHash")); - assert!(debug.contains("0000000000000000000000000000000000000001")); - } - - #[test] - fn display_format() { - let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); - assert_eq!( - format!("{}", hash), - "0000000000000000000000000000000000000001" - ); - } - - #[test] - fn equality() { - let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); - let b = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); - let c = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap(); - - assert_eq!(a, b); - assert_ne!(a, c); - } - - #[test] - fn hash_trait() { - use std::collections::HashSet; - - let mut set = HashSet::new(); - let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); - let b = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap(); - - set.insert(a); - set.insert(b); - set.insert(a); // duplicate - - assert_eq!(set.len(), 2); - } -} +pub use auths_keri::witness::EventHash; diff --git a/crates/auths-core/src/witness/mod.rs b/crates/auths-core/src/witness/mod.rs index 71d3a12b..03d232e8 100644 --- a/crates/auths-core/src/witness/mod.rs +++ b/crates/auths-core/src/witness/mod.rs @@ -85,15 +85,13 @@ mod server; #[cfg(feature = "witness-server")] mod storage; -// Sync provider (backward compat) -pub use hash::{EventHash, EventHashParseError}; +// Re-export KERI witness protocol types from auths-keri +pub use auths_keri::witness::{ + AsyncWitnessProvider, DuplicityEvidence, EventHash, EventHashParseError, KERI_VERSION, + NoOpAsyncWitness, RECEIPT_TYPE, Receipt, ReceiptBuilder, WitnessError, WitnessProvider, + WitnessReport, +}; pub use noop::NoOpWitness; -pub use provider::WitnessProvider; - -// Async provider and types -pub use async_provider::{AsyncWitnessProvider, NoOpAsyncWitness}; -pub use error::{DuplicityEvidence, WitnessError, WitnessReport}; -pub use receipt::{KERI_VERSION, RECEIPT_TYPE, Receipt, ReceiptBuilder}; // Collection and duplicity detection pub use collector::{CollectionError, ReceiptCollector, ReceiptCollectorBuilder}; diff --git a/crates/auths-core/src/witness/noop.rs b/crates/auths-core/src/witness/noop.rs index d2965bd0..f076c61a 100644 --- a/crates/auths-core/src/witness/noop.rs +++ b/crates/auths-core/src/witness/noop.rs @@ -6,7 +6,7 @@ //! - You're in a private/trusted environment //! - The system has other consistency mechanisms (e.g., external consistency logic) -use auths_verifier::keri::Prefix; +use auths_keri::Prefix; use super::hash::EventHash; use super::provider::WitnessProvider; @@ -20,7 +20,7 @@ use super::provider::WitnessProvider; /// /// ```rust /// use auths_core::witness::{WitnessProvider, NoOpWitness}; -/// use auths_verifier::keri::Prefix; +/// use auths_keri::Prefix; /// /// let witness = NoOpWitness; /// let prefix = Prefix::new_unchecked("E123abc".into()); diff --git a/crates/auths-core/src/witness/provider.rs b/crates/auths-core/src/witness/provider.rs index 88714eb7..b3b1b44a 100644 --- a/crates/auths-core/src/witness/provider.rs +++ b/crates/auths-core/src/witness/provider.rs @@ -1,132 +1 @@ -//! Witness provider trait. - -use auths_verifier::keri::Prefix; - -use super::hash::EventHash; - -/// A provider that observes identity KEL heads for split-view detection. -/// -/// Implementations of this trait act as "witnesses" that can report -/// what they believe to be the current head of an identity's KEL. -/// -/// # Thread Safety -/// -/// Implementations must be `Send + Sync` to allow use across threads. -/// This is required because policy evaluation may happen in async contexts. -/// -/// # No Networking in Trait -/// -/// Note that this trait definition contains no networking code. Implementations -/// may use networking internally (e.g., to query remote witnesses), but the -/// trait itself is pure and synchronous. -/// -/// # Example -/// -/// ```rust,ignore -/// use auths_core::witness::{WitnessProvider, NoOpWitness}; -/// use auths_verifier::keri::Prefix; -/// -/// // Default: no witness checking -/// let witness = NoOpWitness; -/// let prefix = Prefix::new_unchecked("E123".into()); -/// assert!(witness.observe_identity_head(&prefix).is_none()); -/// -/// // Custom witness implementation -/// struct MyWitness { /* ... */ } -/// impl WitnessProvider for MyWitness { -/// fn observe_identity_head(&self, prefix: &Prefix) -> Option { -/// // Query witness infrastructure -/// EventHash::from_hex("0123456789abcdef0123456789abcdef01234567") -/// } -/// } -/// ``` -pub trait WitnessProvider: Send + Sync { - /// Observe the current head of an identity's KEL. - /// - /// Returns the hash of the most recent event the witness has seen - /// for the given identity prefix. - /// - /// # Returns - /// - /// - `Some(hash)` - The witness has an opinion on this identity's head - /// - `None` - The witness has no opinion (offline, not tracking, or disabled) - /// - /// # Arguments - /// - /// * `prefix` - The KERI prefix of the identity (e.g., "E123abc...") - fn observe_identity_head(&self, prefix: &Prefix) -> Option; - - /// Get the minimum quorum required for consistency. - /// - /// When multiple witnesses are used, this specifies how many must agree - /// for the head to be considered consistent. - /// - /// # Default - /// - /// Returns `1` (single witness is sufficient). - /// - /// # Quorum Semantics - /// - /// - `quorum = 1`: Any single witness disagreement triggers alert - /// - `quorum = 2`: Requires 2+ witnesses to agree - /// - `quorum = n`: Requires n witnesses to agree (Byzantine quorum) - fn quorum(&self) -> usize { - 1 - } - - /// Check if this witness is enabled. - /// - /// # Default - /// - /// Returns `true`. Override to return `false` for no-op implementations. - fn is_enabled(&self) -> bool { - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct MockWitness { - head: Option, - quorum: usize, - } - - impl WitnessProvider for MockWitness { - fn observe_identity_head(&self, _prefix: &Prefix) -> Option { - self.head - } - - fn quorum(&self) -> usize { - self.quorum - } - } - - #[test] - fn test_default_quorum() { - let witness = MockWitness { - head: None, - quorum: 1, - }; - assert_eq!(witness.quorum(), 1); - } - - #[test] - fn test_custom_quorum() { - let witness = MockWitness { - head: None, - quorum: 3, - }; - assert_eq!(witness.quorum(), 3); - } - - #[test] - fn test_is_enabled_default() { - let witness = MockWitness { - head: None, - quorum: 1, - }; - assert!(witness.is_enabled()); - } -} +pub use auths_keri::witness::WitnessProvider; diff --git a/crates/auths-core/src/witness/receipt.rs b/crates/auths-core/src/witness/receipt.rs index d832309c..e534e8e4 100644 --- a/crates/auths-core/src/witness/receipt.rs +++ b/crates/auths-core/src/witness/receipt.rs @@ -1,381 +1,2 @@ -//! Witness receipt type for KERI event witnessing. -//! -//! A receipt is a signed acknowledgment from a witness that it has observed -//! a specific KEL event. Receipts enable duplicity detection by allowing -//! verifiers to check that witnesses agree on the event history. -//! -//! # KERI Receipt Format -//! -//! This implementation follows the KERI `rct` (non-transferable receipt) format: -//! -//! ```json -//! { -//! "v": "KERI10JSON...", -//! "t": "rct", -//! "d": "", -//! "i": "", -//! "s": "", -//! "a": "", -//! "sig": "" -//! } -//! ``` - -use auths_verifier::keri::Said; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use serde::{Deserialize, Serialize}; - -/// KERI version string for receipts. -pub const KERI_VERSION: &str = "KERI10JSON000000_"; - -/// Receipt type identifier. -pub const RECEIPT_TYPE: &str = "rct"; - -/// A witness receipt for a KEL event. -/// -/// The receipt proves that a witness has observed and acknowledged a specific -/// event. It includes the witness's signature over the event SAID, enabling -/// verifiers to check receipt authenticity. -/// -/// # Serialization -/// -/// The `sig` field uses hex encoding for JSON serialization. -/// -/// # Example -/// -/// ```rust -/// use auths_core::witness::Receipt; -/// use auths_verifier::keri::Said; -/// -/// let receipt = Receipt { -/// v: "KERI10JSON000000_".into(), -/// t: "rct".into(), -/// d: Said::new_unchecked("EReceipt123".into()), -/// i: "did:key:z6MkWitness...".into(), -/// s: 5, -/// a: Said::new_unchecked("EEvent456".into()), -/// sig: vec![0u8; 64], -/// }; -/// -/// let json = serde_json::to_string(&receipt).unwrap(); -/// let parsed: Receipt = serde_json::from_str(&json).unwrap(); -/// assert_eq!(receipt.s, parsed.s); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Receipt { - /// Version string (e.g., "KERI10JSON000000_") - pub v: String, - - /// Type identifier ("rct" for receipt) - pub t: String, - - /// Receipt SAID (Self-Addressing Identifier) - pub d: Said, - - /// Witness identifier (DID) - pub i: String, - - /// Event sequence number being receipted - pub s: u64, - - /// Event SAID being receipted - pub a: Said, - - /// Ed25519 signature over the canonical receipt JSON (excluding sig) - #[serde(with = "hex")] - pub sig: Vec, -} - -impl Receipt { - /// Create a new receipt builder. - pub fn builder() -> ReceiptBuilder { - ReceiptBuilder::new() - } - - /// Check if this receipt is for the given event SAID. - pub fn is_for_event(&self, event_said: &Said) -> bool { - self.a == *event_said - } - - /// Check if this receipt is from the given witness. - pub fn is_from_witness(&self, witness_id: &str) -> bool { - self.i == witness_id - } - - /// Formats this receipt as a Git trailer value (base64url-encoded JSON). - pub fn to_trailer_value(&self) -> Result { - let json = serde_json::to_string(self)?; - Ok(URL_SAFE_NO_PAD.encode(json.as_bytes())) - } - - /// Parses a receipt from a Git trailer value (base64url-encoded JSON). - /// - /// Strips all whitespace before decoding to handle RFC 822 line folding, - /// which may introduce spaces between base64url chunks during unfolding. - pub fn from_trailer_value(value: &str) -> Result { - let clean: String = value.split_whitespace().collect(); - let bytes = URL_SAFE_NO_PAD - .decode(&clean) - .map_err(|e| format!("base64 decode failed: {}", e))?; - serde_json::from_slice(&bytes).map_err(|e| format!("json parse failed: {}", e)) - } - - /// Get the canonical JSON for signing (without the sig field). - /// - /// This produces the JSON that should be signed to create the receipt. - pub fn signing_payload(&self) -> Result, serde_json::Error> { - let payload = ReceiptSigningPayload { - v: &self.v, - t: &self.t, - d: &self.d, - i: &self.i, - s: self.s, - a: &self.a, - }; - serde_json::to_vec(&payload) - } -} - -/// Internal type for signing payload (excludes sig). -#[derive(Serialize)] -struct ReceiptSigningPayload<'a> { - v: &'a str, - t: &'a str, - d: &'a Said, - i: &'a str, - s: u64, - a: &'a Said, -} - -/// Builder for constructing receipts. -#[derive(Debug, Default)] -pub struct ReceiptBuilder { - v: Option, - d: Option, - i: Option, - s: Option, - a: Option, - sig: Option>, -} - -impl ReceiptBuilder { - /// Create a new receipt builder with defaults. - pub fn new() -> Self { - Self { - v: Some(KERI_VERSION.into()), - ..Default::default() - } - } - - /// Set the receipt SAID. - pub fn said(mut self, said: Said) -> Self { - self.d = Some(said); - self - } - - /// Set the witness identifier. - pub fn witness(mut self, witness_id: impl Into) -> Self { - self.i = Some(witness_id.into()); - self - } - - /// Set the event sequence number. - pub fn sequence(mut self, seq: u64) -> Self { - self.s = Some(seq); - self - } - - /// Set the event SAID being receipted. - pub fn event_said(mut self, event_said: Said) -> Self { - self.a = Some(event_said); - self - } - - /// Set the signature. - pub fn signature(mut self, sig: Vec) -> Self { - self.sig = Some(sig); - self - } - - /// Build the receipt. - /// - /// Returns `None` if required fields are missing. - pub fn build(self) -> Option { - Some(Receipt { - v: self.v?, - t: RECEIPT_TYPE.into(), - d: self.d?, - i: self.i?, - s: self.s?, - a: self.a?, - sig: self.sig?, - }) - } -} - -impl From for auths_verifier::witness::WitnessReceipt { - fn from(r: Receipt) -> Self { - Self { - v: r.v, - t: r.t, - d: r.d, - i: r.i, - s: r.s, - a: r.a, - sig: r.sig, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_receipt() -> Receipt { - Receipt { - v: KERI_VERSION.into(), - t: RECEIPT_TYPE.into(), - d: Said::new_unchecked("EReceipt123".into()), - i: "did:key:z6MkWitness".into(), - s: 5, - a: Said::new_unchecked("EEvent456".into()), - sig: vec![0xab; 64], - } - } - - #[test] - fn receipt_serialization_roundtrip() { - let receipt = sample_receipt(); - let json = serde_json::to_string(&receipt).unwrap(); - let parsed: Receipt = serde_json::from_str(&json).unwrap(); - assert_eq!(receipt, parsed); - } - - #[test] - fn receipt_sig_hex_encoded() { - let receipt = sample_receipt(); - let json = serde_json::to_string(&receipt).unwrap(); - // Signature should be hex encoded (64 bytes = 128 hex chars) - assert!(json.contains(&"ab".repeat(64))); - } - - #[test] - fn receipt_is_for_event() { - let receipt = sample_receipt(); - assert!(receipt.is_for_event(&Said::new_unchecked("EEvent456".into()))); - assert!(!receipt.is_for_event(&Said::new_unchecked("EWrongEvent".into()))); - } - - #[test] - fn receipt_is_from_witness() { - let receipt = sample_receipt(); - assert!(receipt.is_from_witness("did:key:z6MkWitness")); - assert!(!receipt.is_from_witness("did:key:z6MkOther")); - } - - #[test] - fn receipt_signing_payload() { - let receipt = sample_receipt(); - let payload = receipt.signing_payload().unwrap(); - let payload_str = String::from_utf8(payload).unwrap(); - - // Payload should NOT contain "sig" - assert!(!payload_str.contains("sig")); - - // But should contain other fields - assert!(payload_str.contains("EReceipt123")); - assert!(payload_str.contains("did:key:z6MkWitness")); - } - - #[test] - fn receipt_builder() { - let receipt = Receipt::builder() - .said(Said::new_unchecked("EReceipt123".into())) - .witness("did:key:z6MkWitness") - .sequence(5) - .event_said(Said::new_unchecked("EEvent456".into())) - .signature(vec![0u8; 64]) - .build() - .unwrap(); - - assert_eq!(receipt.v, KERI_VERSION); - assert_eq!(receipt.t, RECEIPT_TYPE); - assert_eq!(receipt.d, "EReceipt123"); - assert_eq!(receipt.s, 5); - } - - #[test] - fn receipt_builder_missing_fields() { - // Missing required fields should return None - let result = Receipt::builder() - .said(Said::new_unchecked("EReceipt123".into())) - .build(); - assert!(result.is_none()); - } - - #[test] - fn receipt_json_structure() { - let receipt = sample_receipt(); - let json: serde_json::Value = serde_json::to_value(&receipt).unwrap(); - - assert_eq!(json["v"], KERI_VERSION); - assert_eq!(json["t"], RECEIPT_TYPE); - assert_eq!(json["s"], 5); - } - - #[test] - fn from_receipt_to_witness_receipt() { - let receipt = sample_receipt(); - let verifier_receipt: auths_verifier::witness::WitnessReceipt = receipt.clone().into(); - - assert_eq!(verifier_receipt.v, receipt.v); - assert_eq!(verifier_receipt.t, receipt.t); - assert_eq!(verifier_receipt.d, receipt.d); - assert_eq!(verifier_receipt.i, receipt.i); - assert_eq!(verifier_receipt.s, receipt.s); - assert_eq!(verifier_receipt.a, receipt.a); - assert_eq!(verifier_receipt.sig, receipt.sig); - } - - #[test] - fn wire_compat_receipt_json_roundtrip() { - let receipt = sample_receipt(); - let json = serde_json::to_string(&receipt).unwrap(); - // Deserialize the same JSON into verifier's WitnessReceipt - let verifier_receipt: auths_verifier::witness::WitnessReceipt = - serde_json::from_str(&json).unwrap(); - assert_eq!(verifier_receipt.v, receipt.v); - assert_eq!(verifier_receipt.s, receipt.s); - assert_eq!(verifier_receipt.sig, receipt.sig); - } - - #[test] - fn trailer_value_roundtrip() { - let receipt = sample_receipt(); - let encoded = receipt.to_trailer_value().unwrap(); - let decoded = Receipt::from_trailer_value(&encoded).unwrap(); - assert_eq!(receipt, decoded); - } - - #[test] - fn trailer_value_is_base64url() { - let receipt = sample_receipt(); - let encoded = receipt.to_trailer_value().unwrap(); - // base64url uses no padding and no '+' or '/' - assert!(!encoded.contains('=')); - assert!(!encoded.contains('+')); - assert!(!encoded.contains('/')); - } - - #[test] - fn from_trailer_value_invalid_base64() { - let result = Receipt::from_trailer_value("not-valid-base64!!!"); - assert!(result.is_err()); - } - - #[test] - fn from_trailer_value_invalid_json() { - let encoded = URL_SAFE_NO_PAD.encode(b"not json"); - let result = Receipt::from_trailer_value(&encoded); - assert!(result.is_err()); - } -} +#[allow(unused_imports)] +pub use auths_keri::witness::{KERI_VERSION, RECEIPT_TYPE, Receipt}; diff --git a/crates/auths-core/src/witness/server.rs b/crates/auths-core/src/witness/server.rs index d710e89b..f2b40203 100644 --- a/crates/auths-core/src/witness/server.rs +++ b/crates/auths-core/src/witness/server.rs @@ -19,7 +19,7 @@ use std::path::PathBuf; use std::sync::Arc; use auths_crypto::SecureSeed; -use auths_verifier::keri::{Prefix, Said}; +use auths_keri::{Prefix, Said}; use auths_verifier::types::DeviceDID; use axum::{ Json, Router, diff --git a/crates/auths-core/src/witness/storage.rs b/crates/auths-core/src/witness/storage.rs index fad6074a..3688fd80 100644 --- a/crates/auths-core/src/witness/storage.rs +++ b/crates/auths-core/src/witness/storage.rs @@ -15,7 +15,7 @@ use std::path::Path; -use auths_verifier::keri::{Prefix, Said}; +use auths_keri::{Prefix, Said}; use chrono::{DateTime, Utc}; use sqlite::Connection; diff --git a/crates/auths-core/tests/cases/mod.rs b/crates/auths-core/tests/cases/mod.rs index 09420035..ed167bc0 100644 --- a/crates/auths-core/tests/cases/mod.rs +++ b/crates/auths-core/tests/cases/mod.rs @@ -3,5 +3,4 @@ mod key_export; mod namespace; #[cfg(feature = "keychain-pkcs11")] mod pkcs11; -mod said_cross_validation; mod ssh_crypto; diff --git a/crates/auths-core/tests/cases/said_cross_validation.rs b/crates/auths-core/tests/cases/said_cross_validation.rs deleted file mode 100644 index b6a6ceb2..00000000 --- a/crates/auths-core/tests/cases/said_cross_validation.rs +++ /dev/null @@ -1,85 +0,0 @@ -use auths_core::crypto::{compute_next_commitment, compute_said, verify_commitment}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; - -/// Inline copy of the verifier's `compute_said()` logic. -/// SYNC: must match auths-verifier/src/keri.rs `fn compute_said()` -fn verifier_compute_said(data: &[u8]) -> String { - let hash = blake3::hash(data); - format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes())) -} - -/// Inline copy of the verifier's `compute_commitment()` logic. -/// SYNC: must match auths-verifier/src/keri.rs `fn compute_commitment()` -fn verifier_compute_commitment(public_key: &[u8]) -> String { - let hash = blake3::hash(public_key); - format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes())) -} - -#[test] -fn compute_said_matches_verifier_for_random_inputs() { - // Test 1000 random-ish inputs of varying lengths. - for i in 0u32..1000 { - let input: Vec = { - let seed = i.to_le_bytes(); - let hash = blake3::hash(&seed); - // Use the hash bytes as our "random" input, varying length - let len = (i as usize % 128) + 1; - hash.as_bytes()[..len.min(32)].to_vec() - }; - - let core_said = compute_said(&input).to_string(); - let verifier_said = verifier_compute_said(&input); - - assert_eq!( - core_said, - verifier_said, - "SAID mismatch at iteration {i} for input of length {}", - input.len() - ); - } -} - -#[test] -fn compute_commitment_matches_verifier_for_random_keys() { - for i in 0u32..1000 { - // Generate a deterministic 32-byte "public key" from each iteration. - let key = blake3::hash(&i.to_le_bytes()); - let key_bytes = key.as_bytes(); - - let core_commitment = compute_next_commitment(key_bytes); - let verifier_commitment = verifier_compute_commitment(key_bytes); - - assert_eq!( - core_commitment, verifier_commitment, - "Commitment mismatch at iteration {i}" - ); - } -} - -#[test] -fn verify_commitment_round_trips_with_verifier_commitment() { - for i in 0u32..100 { - let key = blake3::hash(&i.to_le_bytes()); - let key_bytes = key.as_bytes(); - - // Compute commitment via verifier logic - let commitment = verifier_compute_commitment(key_bytes); - - // Verify via auths-core's verify_commitment - assert!( - verify_commitment(key_bytes, &commitment), - "verify_commitment failed for verifier-generated commitment at iteration {i}" - ); - } -} - -#[test] -fn said_known_vector() { - // A fixed known-input test to catch algorithm changes. - let input = b"{\"t\":\"icp\",\"s\":\"0\"}"; - let core = compute_said(input).to_string(); - let verifier = verifier_compute_said(input); - assert_eq!(core, verifier); - assert!(core.starts_with('E')); - assert_eq!(core.len(), 44); // 'E' + 43 chars of base64url -} diff --git a/crates/auths-crypto/src/lib.rs b/crates/auths-crypto/src/lib.rs index 444c223f..e62e796e 100644 --- a/crates/auths-crypto/src/lib.rs +++ b/crates/auths-crypto/src/lib.rs @@ -4,12 +4,10 @@ //! from concrete backends, keeping the core dependency-light. //! //! - [`provider`] — Pluggable [`CryptoProvider`] trait for Ed25519 verification -//! - [`keri`] — KERI CESR Ed25519 key parsing (`KeriPublicKey`, `KeriDecodeError`) //! - [`did_key`] — DID:key ↔ Ed25519 encoding (`DidKeyError`, `did_key_to_ed25519`, etc.) pub mod did_key; pub mod error; -pub mod keri; pub mod key_material; pub mod pkcs8; pub mod provider; @@ -23,7 +21,6 @@ pub use did_key::{ DidKeyError, did_key_to_ed25519, ed25519_pubkey_to_did_keri, ed25519_pubkey_to_did_key, }; pub use error::AuthsErrorInfo; -pub use keri::{KeriDecodeError, KeriPublicKey}; pub use key_material::{build_ed25519_pkcs8_v2, parse_ed25519_key_material, parse_ed25519_seed}; pub use pkcs8::Pkcs8Der; pub use provider::{ diff --git a/crates/auths-id/Cargo.toml b/crates/auths-id/Cargo.toml index e71a6e93..3549bea6 100644 --- a/crates/auths-id/Cargo.toml +++ b/crates/auths-id/Cargo.toml @@ -22,6 +22,7 @@ test-utils = ["auths-crypto/test-utils", "dep:mockall"] async-trait = "0.1" auths-core.workspace = true auths-crypto.workspace = true +auths-keri.workspace = true auths-policy.workspace = true auths-verifier = { workspace = true, features = ["native"] } base64.workspace = true diff --git a/crates/auths-id/src/domain/keri_resolve.rs b/crates/auths-id/src/domain/keri_resolve.rs index 48a8627a..cbec7b98 100644 --- a/crates/auths-id/src/domain/keri_resolve.rs +++ b/crates/auths-id/src/domain/keri_resolve.rs @@ -1,4 +1,4 @@ -use auths_crypto::KeriPublicKey; +use auths_keri::KeriPublicKey; use auths_verifier::types::IdentityDID; use super::kel_port::KelPort; diff --git a/crates/auths-id/src/identity/resolve.rs b/crates/auths-id/src/identity/resolve.rs index cf9e46eb..6aa7b1f1 100644 --- a/crates/auths-id/src/identity/resolve.rs +++ b/crates/auths-id/src/identity/resolve.rs @@ -1,6 +1,6 @@ //! DID resolution for did:key and did:keri. -use auths_crypto::KeriPublicKey; +use auths_keri::KeriPublicKey; use auths_verifier::core::Ed25519PublicKey; use git2::Repository; use std::path::Path; diff --git a/crates/auths-id/src/keri/event.rs b/crates/auths-id/src/keri/event.rs index 6e456047..1d2b35bb 100644 --- a/crates/auths-id/src/keri/event.rs +++ b/crates/auths-id/src/keri/event.rs @@ -2,56 +2,22 @@ //! //! These events form the Key Event Log (KEL), a hash-chained sequence //! that records all key lifecycle operations for a KERI identity. +//! +//! The canonical type definitions live in `auths-keri`. This module +//! re-exports them and adds `EventReceipts`, which requires `auths-core`. + +pub use auths_keri::{Event, IcpEvent, IxnEvent, KERI_VERSION, KeriSequence, RotEvent}; use auths_core::witness::Receipt; -use serde::ser::SerializeMap; -use serde::{Deserialize, Serialize, Serializer}; use std::collections::HashSet; -use std::fmt; -use super::seal::Seal; -use super::types::{Prefix, Said}; - -/// A KERI sequence number, stored internally as u64 and serialized as a hex string. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct KeriSequence(u64); - -impl KeriSequence { - pub fn new(value: u64) -> Self { - Self(value) - } - - pub fn value(self) -> u64 { - self.0 - } -} - -impl fmt::Display for KeriSequence { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:x}", self.0) - } -} - -impl Serialize for KeriSequence { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&format!("{:x}", self.0)) - } -} - -impl<'de> Deserialize<'de> for KeriSequence { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - let value = u64::from_str_radix(&s, 16) - .map_err(|_| serde::de::Error::custom(format!("invalid hex sequence: {s:?}")))?; - Ok(KeriSequence(value)) - } -} +use super::types::Said; /// Receipts attached to a KEL event. /// /// Receipts are witness acknowledgments that prove an event was observed. /// They are stored separately from the event itself, linked by SAID. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct EventReceipts { /// Event SAID these receipts are for pub event_said: Said, @@ -61,6 +27,10 @@ pub struct EventReceipts { impl EventReceipts { /// Create a new EventReceipts collection, deduplicating by witness identifier. + /// + /// Args: + /// * `event_said`: SAID of the event these receipts are for. + /// * `receipts`: Receipts from witnesses. pub fn new(event_said: impl Into, receipts: Vec) -> Self { let mut seen = HashSet::new(); let deduped: Vec = receipts @@ -77,8 +47,7 @@ impl EventReceipts { /// /// Args: /// * `threshold`: Minimum number of unique witness receipts required. - /// * `witness_count`: Size of the configured witness set. If unique receipts - /// exceed this, the result is `false` (indicates replay/duplication). + /// * `witness_count`: Size of the configured witness set. pub fn meets_threshold(&self, threshold: usize, witness_count: usize) -> bool { let unique = self.unique_witness_count(); if unique > witness_count { @@ -105,499 +74,11 @@ impl EventReceipts { } } -/// Inception event - creates a new KERI identity. -/// -/// The inception event establishes the identifier prefix and commits -/// to the first rotation key via the `n` (next) field. -/// -/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct IcpEvent { - /// Version string: "KERI10JSON" - pub v: String, - /// SAID (Self-Addressing Identifier) - Blake3 hash of event - #[serde(default)] - pub d: Said, - /// Identifier prefix (same as `d` for inception) - pub i: Prefix, - /// Sequence number - pub s: KeriSequence, - /// Key threshold: "1" for single-sig - pub kt: String, - /// Current public key(s), Base64url encoded with derivation code - pub k: Vec, - /// Next key threshold: "1" - pub nt: String, - /// Next key commitment(s) - hash of next public key(s) - pub n: Vec, - /// Witness threshold: "0" (no witnesses) - pub bt: String, - /// Witness list (empty) - pub b: Vec, - /// Anchored seals - #[serde(default)] - pub a: Vec, - /// Event signature (Ed25519 over canonical event with empty d, i, and x fields) - #[serde(default)] - pub x: String, -} - -/// Spec field order: v, t, d, i, s, kt, k, nt, n, bt, b, a, x -impl Serialize for IcpEvent { - fn serialize(&self, serializer: S) -> Result { - let field_count = 12 - + (!self.d.is_empty() as usize) - + (!self.a.is_empty() as usize) - + (!self.x.is_empty() as usize); - let mut map = serializer.serialize_map(Some(field_count))?; - map.serialize_entry("v", &self.v)?; - map.serialize_entry("t", "icp")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } - map.serialize_entry("i", &self.i)?; - map.serialize_entry("s", &self.s)?; - map.serialize_entry("kt", &self.kt)?; - map.serialize_entry("k", &self.k)?; - map.serialize_entry("nt", &self.nt)?; - map.serialize_entry("n", &self.n)?; - map.serialize_entry("bt", &self.bt)?; - map.serialize_entry("b", &self.b)?; - if !self.a.is_empty() { - map.serialize_entry("a", &self.a)?; - } - if !self.x.is_empty() { - map.serialize_entry("x", &self.x)?; - } - map.end() - } -} - -/// Rotation event - rotates to pre-committed key. -/// -/// The new key must match the previous event's next-key commitment. -/// This provides cryptographic pre-rotation security. -/// -/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct RotEvent { - /// Version string - pub v: String, - /// SAID of this event - #[serde(default)] - pub d: Said, - /// Identifier prefix - pub i: Prefix, - /// Sequence number (increments with each event) - pub s: KeriSequence, - /// Previous event SAID (creates the hash chain) - pub p: Said, - /// Key threshold - pub kt: String, - /// New current key(s) - pub k: Vec, - /// Next key threshold - pub nt: String, - /// New next key commitment(s) - pub n: Vec, - /// Witness threshold - pub bt: String, - /// Witness list - pub b: Vec, - /// Anchored seals - #[serde(default)] - pub a: Vec, - /// Event signature (Ed25519 over canonical event with empty d and x fields) - #[serde(default)] - pub x: String, -} - -/// Spec field order: v, t, d, i, s, p, kt, k, nt, n, bt, b, a, x -impl Serialize for RotEvent { - fn serialize(&self, serializer: S) -> Result { - let field_count = 13 - + (!self.d.is_empty() as usize) - + (!self.a.is_empty() as usize) - + (!self.x.is_empty() as usize); - let mut map = serializer.serialize_map(Some(field_count))?; - map.serialize_entry("v", &self.v)?; - map.serialize_entry("t", "rot")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } - map.serialize_entry("i", &self.i)?; - map.serialize_entry("s", &self.s)?; - map.serialize_entry("p", &self.p)?; - map.serialize_entry("kt", &self.kt)?; - map.serialize_entry("k", &self.k)?; - map.serialize_entry("nt", &self.nt)?; - map.serialize_entry("n", &self.n)?; - map.serialize_entry("bt", &self.bt)?; - map.serialize_entry("b", &self.b)?; - if !self.a.is_empty() { - map.serialize_entry("a", &self.a)?; - } - if !self.x.is_empty() { - map.serialize_entry("x", &self.x)?; - } - map.end() - } -} - -/// Interaction event - anchors data without key rotation. -/// -/// Used to anchor attestations, delegations, or other data -/// in the KEL without changing keys. -/// -/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct IxnEvent { - /// Version string - pub v: String, - /// SAID of this event - #[serde(default)] - pub d: Said, - /// Identifier prefix - pub i: Prefix, - /// Sequence number - pub s: KeriSequence, - /// Previous event SAID - pub p: Said, - /// Anchored seals (the main purpose of IXN events) - pub a: Vec, - /// Event signature (Ed25519 over canonical event with empty d and x fields) - #[serde(default)] - pub x: String, -} - -/// Spec field order: v, t, d, i, s, p, a, x -impl Serialize for IxnEvent { - fn serialize(&self, serializer: S) -> Result { - let field_count = 7 + (!self.d.is_empty() as usize) + (!self.x.is_empty() as usize); - let mut map = serializer.serialize_map(Some(field_count))?; - map.serialize_entry("v", &self.v)?; - map.serialize_entry("t", "ixn")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } - map.serialize_entry("i", &self.i)?; - map.serialize_entry("s", &self.s)?; - map.serialize_entry("p", &self.p)?; - map.serialize_entry("a", &self.a)?; - if !self.x.is_empty() { - map.serialize_entry("x", &self.x)?; - } - map.end() - } -} - -/// Unified event enum for processing any KERI event type. -/// -/// Uses serde's tagged enum feature to deserialize based on the `t` field. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(tag = "t")] -pub enum Event { - /// Inception event - #[serde(rename = "icp")] - Icp(IcpEvent), - /// Rotation event - #[serde(rename = "rot")] - Rot(RotEvent), - /// Interaction event - #[serde(rename = "ixn")] - Ixn(IxnEvent), -} - -impl Serialize for Event { - fn serialize(&self, serializer: S) -> Result { - match self { - Event::Icp(e) => e.serialize(serializer), - Event::Rot(e) => e.serialize(serializer), - Event::Ixn(e) => e.serialize(serializer), - } - } -} - -impl Event { - /// Get the SAID (Self-Addressing Identifier) of this event. - pub fn said(&self) -> &Said { - match self { - Event::Icp(e) => &e.d, - Event::Rot(e) => &e.d, - Event::Ixn(e) => &e.d, - } - } - - /// Get the signature of this event. - pub fn signature(&self) -> &str { - match self { - Event::Icp(e) => &e.x, - Event::Rot(e) => &e.x, - Event::Ixn(e) => &e.x, - } - } - - /// Get the sequence number of this event. - pub fn sequence(&self) -> KeriSequence { - match self { - Event::Icp(e) => e.s, - Event::Rot(e) => e.s, - Event::Ixn(e) => e.s, - } - } - - /// Get the identifier prefix. - pub fn prefix(&self) -> &Prefix { - match self { - Event::Icp(e) => &e.i, - Event::Rot(e) => &e.i, - Event::Ixn(e) => &e.i, - } - } - - /// Get the previous event SAID (None for inception). - pub fn previous(&self) -> Option<&Said> { - match self { - Event::Icp(_) => None, - Event::Rot(e) => Some(&e.p), - Event::Ixn(e) => Some(&e.p), - } - } - - /// Get the current keys (only applicable to ICP and ROT events). - pub fn keys(&self) -> Option<&[String]> { - match self { - Event::Icp(e) => Some(&e.k), - Event::Rot(e) => Some(&e.k), - Event::Ixn(_) => None, - } - } - - /// Get the next key commitments (only applicable to ICP and ROT events). - pub fn next_commitments(&self) -> Option<&[String]> { - match self { - Event::Icp(e) => Some(&e.n), - Event::Rot(e) => Some(&e.n), - Event::Ixn(_) => None, - } - } - - /// Get the anchored seals. - pub fn anchors(&self) -> &[Seal] { - match self { - Event::Icp(e) => &e.a, - Event::Rot(e) => &e.a, - Event::Ixn(e) => &e.a, - } - } - - /// Check if this is an inception event. - pub fn is_inception(&self) -> bool { - matches!(self, Event::Icp(_)) - } - - /// Check if this is a rotation event. - pub fn is_rotation(&self) -> bool { - matches!(self, Event::Rot(_)) - } - - /// Check if this is an interaction event. - pub fn is_interaction(&self) -> bool { - matches!(self, Event::Ixn(_)) - } -} - #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; - use crate::keri::KERI_VERSION; - - #[test] - fn keri_sequence_serializes_as_hex() { - let seq = KeriSequence::new(0); - assert_eq!(serde_json::to_string(&seq).unwrap(), "\"0\""); - - let seq = KeriSequence::new(10); - assert_eq!(serde_json::to_string(&seq).unwrap(), "\"a\""); - - let seq = KeriSequence::new(255); - assert_eq!(serde_json::to_string(&seq).unwrap(), "\"ff\""); - } - - #[test] - fn keri_sequence_deserializes_from_hex() { - let seq: KeriSequence = serde_json::from_str("\"0\"").unwrap(); - assert_eq!(seq.value(), 0); - - let seq: KeriSequence = serde_json::from_str("\"a\"").unwrap(); - assert_eq!(seq.value(), 10); - - let seq: KeriSequence = serde_json::from_str("\"ff\"").unwrap(); - assert_eq!(seq.value(), 255); - } - - #[test] - fn keri_sequence_rejects_invalid_hex() { - let result = serde_json::from_str::("\"not_hex\""); - assert!(result.is_err()); - } - - #[test] - fn icp_event_serializes_correctly() { - let icp = IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::new_unchecked("ETest123".to_string()), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec!["DKey123".to_string()], - nt: "1".to_string(), - n: vec!["ENext456".to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - let json = serde_json::to_string(&icp).unwrap(); - assert!(json.contains("\"v\":\"KERI10JSON\"")); - assert!(json.contains("\"k\":[\"DKey123\"]")); - assert!(json.contains("\"s\":\"0\"")); - // Empty d, a, and x should be skipped - assert!(!json.contains("\"d\":\"\"")); - assert!(!json.contains("\"a\":[]")); - assert!(!json.contains("\"x\":\"\"")); - } - - #[test] - fn rot_event_serializes_correctly() { - let rot = RotEvent { - v: KERI_VERSION.to_string(), - d: Said::new_unchecked("ERotSaid".to_string()), - i: Prefix::new_unchecked("EPrefix".to_string()), - s: KeriSequence::new(1), - p: Said::new_unchecked("EPrevSaid".to_string()), - kt: "1".to_string(), - k: vec!["DNewKey".to_string()], - nt: "1".to_string(), - n: vec!["ENextCommit".to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - let json = serde_json::to_string(&rot).unwrap(); - assert!(json.contains("\"p\":\"EPrevSaid\"")); - assert!(json.contains("\"s\":\"1\"")); - } - - #[test] - fn ixn_event_serializes_correctly() { - let seal = Seal::device_attestation("EAttestDigest"); - let ixn = IxnEvent { - v: KERI_VERSION.to_string(), - d: Said::new_unchecked("EIxnSaid".to_string()), - i: Prefix::new_unchecked("EPrefix".to_string()), - s: KeriSequence::new(2), - p: Said::new_unchecked("EPrevSaid".to_string()), - a: vec![seal], - x: String::new(), - }; - let json = serde_json::to_string(&ixn).unwrap(); - assert!(json.contains("\"a\":[")); - assert!(json.contains("device-attestation")); - } - - #[test] - fn event_enum_deserializes_icp_by_type() { - let json = r#"{"v":"KERI10JSON","t":"icp","i":"E123","s":"0","kt":"1","k":["DKey"],"nt":"1","n":["ENext"],"bt":"0","b":[]}"#; - let event: Event = serde_json::from_str(json).unwrap(); - assert!(event.is_inception()); - assert_eq!(event.prefix(), "E123"); - assert_eq!(event.sequence().value(), 0); - assert!(event.previous().is_none()); - } - - #[test] - fn event_enum_deserializes_rot_by_type() { - let json = r#"{"v":"KERI10JSON","t":"rot","d":"ENew","i":"E123","s":"1","p":"EPrev","kt":"1","k":["DKey"],"nt":"1","n":["ENext"],"bt":"0","b":[]}"#; - let event: Event = serde_json::from_str(json).unwrap(); - assert!(event.is_rotation()); - assert_eq!(event.sequence().value(), 1); - assert_eq!(event.previous().map(|s| s.as_str()), Some("EPrev")); - } - - #[test] - fn event_enum_deserializes_ixn_by_type() { - let json = - r#"{"v":"KERI10JSON","t":"ixn","d":"EIxn","i":"E123","s":"2","p":"EPrev","a":[]}"#; - let event: Event = serde_json::from_str(json).unwrap(); - assert!(event.is_interaction()); - assert_eq!(event.sequence().value(), 2); - assert!(event.keys().is_none()); - } - - #[test] - fn event_keys_and_next_accessors() { - let icp = Event::Icp(IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::new_unchecked("ETest".to_string()), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec!["DKey1".to_string(), "DKey2".to_string()], - nt: "1".to_string(), - n: vec!["ENext1".to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }); - - assert_eq!( - icp.keys(), - Some(&["DKey1".to_string(), "DKey2".to_string()][..]) - ); - assert_eq!(icp.next_commitments(), Some(&["ENext1".to_string()][..])); - } - - #[test] - fn event_anchors_accessor() { - let seal = Seal::device_attestation("EDigest"); - let ixn = Event::Ixn(IxnEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::new_unchecked("ETest".to_string()), - s: KeriSequence::new(1), - p: Said::new_unchecked("EPrev".to_string()), - a: vec![seal.clone()], - x: String::new(), - }); - - assert_eq!(ixn.anchors().len(), 1); - assert_eq!(ixn.anchors()[0].d, "EDigest"); - } - - #[test] - fn icp_event_roundtrips() { - let icp = IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::new_unchecked("ESaid".to_string()), - i: Prefix::new_unchecked("ESaid".to_string()), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec!["DKey".to_string()], - nt: "1".to_string(), - n: vec!["ENext".to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![Seal::device_attestation("EAttest")], - x: String::new(), - }; - - let json = serde_json::to_string(&icp).unwrap(); - let parsed: IcpEvent = serde_json::from_str(&json).unwrap(); - assert_eq!(icp, parsed); - } + use crate::keri::{KERI_VERSION, Seal}; fn make_receipt(witness_id: &str) -> Receipt { Receipt { @@ -635,7 +116,6 @@ mod tests { make_receipt("did:key:w3"), ], ); - // 3 unique receipts but witness_count is 2 — anomalous assert!(!receipts.meets_threshold(1, 2)); } @@ -648,4 +128,30 @@ mod tests { assert!(receipts.meets_threshold(2, 3)); assert!(!receipts.meets_threshold(3, 3)); } + + #[test] + fn keri_version_constant_is_correct() { + assert_eq!(KERI_VERSION, "KERI10JSON"); + } + + #[test] + fn icp_event_is_reexported_and_works() { + let icp = IcpEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: crate::keri::Prefix::new_unchecked("ETest123".to_string()), + s: KeriSequence::new(0), + kt: "1".to_string(), + k: vec!["DKey123".to_string()], + nt: "1".to_string(), + n: vec!["ENext456".to_string()], + bt: "0".to_string(), + b: vec![], + a: vec![Seal::device_attestation("EAttest")], + x: String::new(), + }; + let json = serde_json::to_string(&icp).unwrap(); + assert!(json.contains("\"s\":\"0\"")); + assert!(json.contains("\"a\":")); + } } diff --git a/crates/auths-id/src/keri/mod.rs b/crates/auths-id/src/keri/mod.rs index 69879a7b..1554c11e 100644 --- a/crates/auths-id/src/keri/mod.rs +++ b/crates/auths-id/src/keri/mod.rs @@ -124,6 +124,7 @@ pub use anchor::{ AnchorError, AnchorVerification, anchor_attestation, anchor_data, anchor_idp_binding, find_anchor_event, verify_anchor, verify_anchor_by_digest, verify_attestation_anchor_by_issuer, }; +pub use auths_keri::KERI_VERSION; pub use event::{Event, EventReceipts, IcpEvent, IxnEvent, KeriSequence, RotEvent}; #[cfg(feature = "git-storage")] pub use inception::{ @@ -148,6 +149,3 @@ pub use validate::{ ValidationError, compute_event_said, finalize_icp_event, replay_kel, serialize_for_signing, validate_for_append, validate_kel, verify_event_crypto, verify_event_said, }; - -/// KERI protocol version string -pub const KERI_VERSION: &str = "KERI10JSON"; diff --git a/crates/auths-id/src/keri/resolve.rs b/crates/auths-id/src/keri/resolve.rs index 17e5c07c..cb669779 100644 --- a/crates/auths-id/src/keri/resolve.rs +++ b/crates/auths-id/src/keri/resolve.rs @@ -5,7 +5,7 @@ //! 2. Replaying all events to derive current KeyState //! 3. Decoding the current public key -use auths_crypto::KeriPublicKey; +use auths_keri::KeriPublicKey; use auths_verifier::types::IdentityDID; use git2::Repository; diff --git a/crates/auths-id/src/keri/seal.rs b/crates/auths-id/src/keri/seal.rs index 10bfcfd2..5ff2f75d 100644 --- a/crates/auths-id/src/keri/seal.rs +++ b/crates/auths-id/src/keri/seal.rs @@ -1,155 +1,5 @@ //! Seal types for anchoring data in KERI events. //! -//! A seal is a cryptographic commitment (digest) to external data that is -//! anchored in a KERI event. This creates a verifiable link between the -//! KEL and external artifacts like attestations. +//! The canonical type definitions live in `auths-keri`. This module re-exports them. -use serde::{Deserialize, Serialize}; -use std::fmt; - -use super::types::Said; - -/// Type of data anchored by a seal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -#[non_exhaustive] -pub enum SealType { - DeviceAttestation, - Revocation, - Delegation, - IdpBinding, -} - -impl fmt::Display for SealType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SealType::DeviceAttestation => write!(f, "device-attestation"), - SealType::Revocation => write!(f, "revocation"), - SealType::Delegation => write!(f, "delegation"), - SealType::IdpBinding => write!(f, "idp-binding"), - } - } -} - -/// A seal anchors external data in a KERI event. -/// -/// Seals are included in the `a` (anchors) field of KERI events. -/// They contain a digest of the anchored data and a type indicator. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Seal { - /// SAID (digest) of the anchored data - pub d: Said, - - /// Type of anchored data - #[serde(rename = "type")] - pub seal_type: SealType, -} - -impl Seal { - /// Create a new seal with the given digest and type. - pub fn new(digest: impl Into, seal_type: SealType) -> Self { - Self { - d: Said::new_unchecked(digest.into()), - seal_type, - } - } - - /// Create a seal for a device attestation. - /// - /// # Arguments - /// * `attestation_digest` - The SAID of the attestation JSON - pub fn device_attestation(attestation_digest: impl Into) -> Self { - Self::new(attestation_digest, SealType::DeviceAttestation) - } - - /// Create a seal for a revocation. - pub fn revocation(revocation_digest: impl Into) -> Self { - Self::new(revocation_digest, SealType::Revocation) - } - - /// Create a seal for capability delegation. - pub fn delegation(delegation_digest: impl Into) -> Self { - Self::new(delegation_digest, SealType::Delegation) - } - - /// Create a seal for an IdP binding. - pub fn idp_binding(binding_digest: impl Into) -> Self { - Self::new(binding_digest, SealType::IdpBinding) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn seal_creates_device_attestation() { - let seal = Seal::device_attestation("EDigest123"); - assert_eq!(seal.seal_type, SealType::DeviceAttestation); - assert_eq!(seal.d, "EDigest123"); - } - - #[test] - fn seal_serializes_with_type_field() { - let seal = Seal::new("ETest", SealType::Revocation); - let json = serde_json::to_string(&seal).unwrap(); - assert!(json.contains(r#""type":"revocation""#)); - assert!(json.contains(r#""d":"ETest""#)); - } - - #[test] - fn seal_deserializes_correctly() { - let json = r#"{"d":"EDigest","type":"device-attestation"}"#; - let seal: Seal = serde_json::from_str(json).unwrap(); - assert_eq!(seal.d, "EDigest"); - assert_eq!(seal.seal_type, SealType::DeviceAttestation); - } - - #[test] - fn seal_roundtrips() { - let original = Seal::device_attestation("ETest123"); - let json = serde_json::to_string(&original).unwrap(); - let parsed: Seal = serde_json::from_str(&json).unwrap(); - assert_eq!(original, parsed); - } - - #[test] - fn seal_creates_idp_binding() { - let seal = Seal::idp_binding("EBindingDigest"); - assert_eq!(seal.seal_type, SealType::IdpBinding); - assert_eq!(seal.d, "EBindingDigest"); - } - - #[test] - fn seal_idp_binding_serializes() { - let seal = Seal::idp_binding("ETest"); - let json = serde_json::to_string(&seal).unwrap(); - assert!(json.contains(r#""type":"idp-binding""#)); - } - - #[test] - fn seal_idp_binding_deserializes() { - let json = r#"{"d":"EDigest","type":"idp-binding"}"#; - let seal: Seal = serde_json::from_str(json).unwrap(); - assert_eq!(seal.seal_type, SealType::IdpBinding); - } - - #[test] - fn seal_idp_binding_roundtrips() { - let original = Seal::idp_binding("EBinding123"); - let json = serde_json::to_string(&original).unwrap(); - let parsed: Seal = serde_json::from_str(&json).unwrap(); - assert_eq!(original, parsed); - } - - #[test] - fn seal_type_display() { - assert_eq!( - SealType::DeviceAttestation.to_string(), - "device-attestation" - ); - assert_eq!(SealType::Revocation.to_string(), "revocation"); - assert_eq!(SealType::Delegation.to_string(), "delegation"); - assert_eq!(SealType::IdpBinding.to_string(), "idp-binding"); - } -} +pub use auths_keri::{Seal, SealType}; diff --git a/crates/auths-id/src/keri/state.rs b/crates/auths-id/src/keri/state.rs index 95b30d47..9e3934c4 100644 --- a/crates/auths-id/src/keri/state.rs +++ b/crates/auths-id/src/keri/state.rs @@ -1,221 +1,2 @@ -//! Key state derived from replaying a KERI event log. -//! -//! The `KeyState` represents the current cryptographic state of a KERI -//! identity after processing all events in its KEL. This is the "resolved" -//! state used for signature verification and capability checking. - -use serde::{Deserialize, Serialize}; - -use super::types::{Prefix, Said}; - -/// Current key state derived from replaying a KEL. -/// -/// This struct captures the complete state of a KERI identity at a given -/// point in its event log. It is computed by walking the KEL from inception -/// to the latest event. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct KeyState { - /// The KERI identifier prefix (used in `did:keri:`) - pub prefix: Prefix, - - /// Current signing key(s), Base64url encoded with derivation code prefix. - /// For Ed25519 keys, this is "D" + base64url(pubkey). - pub current_keys: Vec, - - /// Next key commitment(s) for pre-rotation. - /// These are Blake3 hashes of the next public key(s). - pub next_commitment: Vec, - - /// Current sequence number (0 for inception, increments with each event) - pub sequence: u64, - - /// SAID of the last processed event - pub last_event_said: Said, - - /// Whether this identity has been abandoned (empty next commitment) - pub is_abandoned: bool, - /// Current signing threshold - pub threshold: u64, - /// Next signing threshold (committed) - pub next_threshold: u64, -} - -impl KeyState { - /// Create initial state from an inception event. - /// - /// # Arguments - /// * `prefix` - The KERI identifier (same as inception SAID) - /// * `keys` - The initial signing key(s) - /// * `next` - The next-key commitment(s) - /// * `threshold` - Initial signing threshold - /// * `next_threshold` - Committed next signing threshold - /// * `said` - The inception event SAID - pub fn from_inception( - prefix: Prefix, - keys: Vec, - next: Vec, - threshold: u64, - next_threshold: u64, - said: Said, - ) -> Self { - Self { - prefix, - current_keys: keys, - next_commitment: next.clone(), - sequence: 0, - last_event_said: said, - is_abandoned: next.is_empty(), - threshold, - next_threshold, - } - } - - /// Apply a rotation event to update state. - /// - /// This should only be called after verifying: - /// 1. The new key matches the previous next_commitment - /// 2. The event's previous SAID matches last_event_said - /// 3. The sequence is exactly last_sequence + 1 - pub fn apply_rotation( - &mut self, - new_keys: Vec, - new_next: Vec, - threshold: u64, - next_threshold: u64, - sequence: u64, - said: Said, - ) { - self.current_keys = new_keys; - self.next_commitment = new_next.clone(); - self.threshold = threshold; - self.next_threshold = next_threshold; - self.sequence = sequence; - self.last_event_said = said; - self.is_abandoned = new_next.is_empty(); - } - - /// Apply an interaction event (updates sequence and SAID only). - /// - /// Interaction events anchor data but don't change keys. - pub fn apply_interaction(&mut self, sequence: u64, said: Said) { - self.sequence = sequence; - self.last_event_said = said; - } - - /// Get the current signing key (first key for single-sig). - /// - /// Returns the encoded key string (e.g., "DBase64EncodedKey...") - pub fn current_key(&self) -> Option<&str> { - self.current_keys.first().map(|s| s.as_str()) - } - - /// Check if key can be rotated. - /// - /// Returns `false` if the identity has been abandoned (empty next commitment). - pub fn can_rotate(&self) -> bool { - !self.is_abandoned && !self.next_commitment.is_empty() - } - - /// Get the DID for this identity. - pub fn did(&self) -> String { - format!("did:keri:{}", self.prefix.as_str()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn key_state_from_inception() { - let state = KeyState::from_inception( - Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, - Said::new_unchecked("ESAID".to_string()), - ); - assert_eq!(state.sequence, 0); - assert!(!state.is_abandoned); - assert!(state.can_rotate()); - assert_eq!(state.current_key(), Some("DKey1")); - assert_eq!(state.did(), "did:keri:EPrefix"); - } - - #[test] - fn key_state_apply_rotation() { - let mut state = KeyState::from_inception( - Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, - Said::new_unchecked("ESAID1".to_string()), - ); - - state.apply_rotation( - vec!["DKey2".to_string()], - vec!["ENext2".to_string()], - 1, - 1, - 1, - Said::new_unchecked("ESAID2".to_string()), - ); - - assert_eq!(state.sequence, 1); - assert_eq!(state.current_keys[0], "DKey2"); - assert_eq!(state.next_commitment[0], "ENext2"); - assert_eq!(state.last_event_said, "ESAID2"); - assert!(state.can_rotate()); - } - - #[test] - fn key_state_apply_interaction() { - let mut state = KeyState::from_inception( - Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, - Said::new_unchecked("ESAID1".to_string()), - ); - - state.apply_interaction(1, Said::new_unchecked("ESAID_IXN".to_string())); - - assert_eq!(state.sequence, 1); - // Keys should not change - assert_eq!(state.current_keys[0], "DKey1"); - assert_eq!(state.last_event_said, "ESAID_IXN"); - } - - #[test] - fn abandoned_identity_cannot_rotate() { - let state = KeyState::from_inception( - Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec![], // Empty next commitment = abandoned - 1, - 0, - Said::new_unchecked("ESAID".to_string()), - ); - assert!(state.is_abandoned); - assert!(!state.can_rotate()); - } - - #[test] - fn key_state_serializes() { - let state = KeyState::from_inception( - Prefix::new_unchecked("EPrefix".to_string()), - vec!["DKey1".to_string()], - vec!["ENext1".to_string()], - 1, - 1, - Said::new_unchecked("ESAID".to_string()), - ); - - let json = serde_json::to_string(&state).unwrap(); - let parsed: KeyState = serde_json::from_str(&json).unwrap(); - assert_eq!(state, parsed); - } -} +//! Key state re-exported from auths-keri. +pub use auths_keri::KeyState; diff --git a/crates/auths-id/src/keri/types.rs b/crates/auths-id/src/keri/types.rs index de424c76..2d4e240e 100644 --- a/crates/auths-id/src/keri/types.rs +++ b/crates/auths-id/src/keri/types.rs @@ -4,7 +4,7 @@ //! (the leaf dependency shared by all crates). Adds `IdentityDID` conversion //! helpers that require `auths-core` types. -pub use auths_verifier::keri::{KeriTypeError, Prefix, Said}; +pub use auths_keri::{KeriTypeError, Prefix, Said}; use auths_core::error::AgentError; use auths_core::storage::keychain::IdentityDID; diff --git a/crates/auths-id/src/keri/validate.rs b/crates/auths-id/src/keri/validate.rs index abc52708..f75be4da 100644 --- a/crates/auths-id/src/keri/validate.rs +++ b/crates/auths-id/src/keri/validate.rs @@ -1,1344 +1,6 @@ -//! KEL validation: SAID verification, chain linkage, signature verification, -//! and pre-rotation commitment checks. -//! -//! This module provides validation functions for ensuring a Key Event Log -//! is cryptographically valid and properly chained. -//! -//! ## Core Entrypoints (Pure Functions) -//! -//! The following functions are **pure** with no side effects: -//! -//! - [`validate_kel`] / [`replay_kel`]: Replays a KEL to compute the current `KeyState` -//! -//! **What "pure" means for these functions:** -//! - **Deterministic**: Same inputs always produce same outputs -//! - **No side effects**: No filesystem, network, or global state access -//! - **No storage assumptions**: Takes `&[Event]` slice, not registry references -//! - **Errors are values**: Returns `Result`, never panics on invalid input -//! -//! This enables property-based testing and makes the core logic independent of -//! storage backends. - -use auths_core::crypto::said::{compute_said, verify_commitment}; -use auths_crypto::KeriPublicKey; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use ring::signature::UnparsedPublicKey; - -use super::types::{Prefix, Said}; -use super::{Event, IcpEvent, IxnEvent, KeyState, RotEvent}; - -/// Errors specific to KEL validation. -/// -/// These errors represent **protocol invariant violations**. They indicate -/// structural corruption or attack, not recoverable conditions. -/// -/// # Invariants Enforced -/// -/// - **Append-only KEL**: Sequence numbers must be monotonically increasing -/// - **Self-addressing**: Each event's SAID must match its content hash -/// - **Chain integrity**: Each event must reference the previous event's SAID -#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum ValidationError { - /// SAID (Self-Addressing Identifier) doesn't match content hash. - /// - /// This is a **protocol invariant violation**. The event's `d` field - /// must equal the Blake3 hash of its canonical serialization. - #[error("Invalid SAID: expected {expected}, got {actual}")] - InvalidSaid { expected: Said, actual: Said }, - - /// Event references wrong previous event. - /// - /// This is a **chain integrity violation**. Each event's `p` field - /// must equal the SAID of the immediately preceding event. - #[error("Broken chain: event {sequence} references {referenced}, but previous was {actual}")] - BrokenChain { - sequence: u64, - referenced: Said, - actual: Said, - }, - - /// Sequence number is not monotonically increasing. - /// - /// This is an **append-only invariant violation**. Sequence numbers - /// must be 0, 1, 2, ... with no gaps or duplicates. - #[error("Invalid sequence: expected {expected}, got {actual}")] - InvalidSequence { expected: u64, actual: u64 }, - - #[error("Pre-rotation commitment mismatch at sequence {sequence}")] - CommitmentMismatch { sequence: u64 }, - - #[error("Signature verification failed at sequence {sequence}")] - SignatureFailed { sequence: u64 }, - - #[error("First event must be inception")] - NotInception, - - #[error("Empty KEL")] - EmptyKel, - - #[error("Multiple inception events in KEL")] - MultipleInceptions, - - #[error("Serialization error: {0}")] - Serialization(String), - - #[error("Malformed sequence number: {raw:?}")] - MalformedSequence { raw: String }, -} - -impl auths_core::error::AuthsErrorInfo for ValidationError { - fn error_code(&self) -> &'static str { - match self { - Self::InvalidSaid { .. } => "AUTHS-E4501", - Self::BrokenChain { .. } => "AUTHS-E4502", - Self::InvalidSequence { .. } => "AUTHS-E4503", - Self::CommitmentMismatch { .. } => "AUTHS-E4504", - Self::SignatureFailed { .. } => "AUTHS-E4505", - Self::NotInception => "AUTHS-E4506", - Self::EmptyKel => "AUTHS-E4507", - Self::MultipleInceptions => "AUTHS-E4508", - Self::Serialization(_) => "AUTHS-E4509", - Self::MalformedSequence { .. } => "AUTHS-E4510", - } - } - - fn suggestion(&self) -> Option<&'static str> { - match self { - Self::InvalidSaid { .. } => { - Some("The KEL may have been tampered with; re-sync from a trusted source") - } - Self::BrokenChain { .. } => { - Some("The KEL chain is broken; re-sync from a trusted source") - } - Self::InvalidSequence { .. } => { - Some("The KEL has sequence gaps; re-sync from a trusted source") - } - Self::CommitmentMismatch { .. } => { - Some("The rotation key does not match the pre-rotation commitment") - } - Self::SignatureFailed { .. } => { - Some("The event signature is invalid; the KEL may be corrupted") - } - Self::NotInception => Some("The first event in a KEL must be an inception event"), - Self::EmptyKel => Some("No events found; initialize the identity first"), - Self::MultipleInceptions => Some("A KEL must contain exactly one inception event"), - Self::Serialization(_) => None, - Self::MalformedSequence { .. } => None, - } - } -} - -/// Validate a KEL and return the resulting KeyState. -/// -/// This is a **pure function** and serves as the core entrypoint for -/// KEL replay. It is equivalent to `apply_event_chain` in the domain model. -/// -/// # Pure Function Guarantees -/// -/// - **Deterministic**: Same event sequence always produces same `KeyState` -/// - **No I/O**: No filesystem, network, or global state access -/// - **No storage assumptions**: Takes `&[Event]` slice directly -/// - **Errors are values**: Returns `Result`, never panics on invalid input -/// -/// # Validation Performed -/// -/// - SAID verification for each event -/// - Chain linkage (each event's `p` matches previous event's `d`) -/// - Sequence ordering (strict increment from 0) -/// - Pre-rotation commitment verification for rotation events -/// - Signature verification using declared keys -/// -/// # Example -/// -/// ```rust,ignore -/// use auths_id::keri::{validate_kel, Event}; -/// -/// let events: Vec = load_events_from_storage(...); -/// let key_state = validate_kel(&events)?; -/// // key_state now reflects the current identity state -/// ``` -pub fn validate_kel(events: &[Event]) -> Result { - if events.is_empty() { - return Err(ValidationError::EmptyKel); - } - - let Event::Icp(icp) = &events[0] else { - return Err(ValidationError::NotInception); - }; - - verify_event_said(&events[0])?; - let mut state = validate_inception(icp)?; - - for (idx, event) in events.iter().enumerate().skip(1) { - let expected_seq = idx as u64; - verify_event_said(event)?; - verify_sequence(event, expected_seq)?; - verify_chain_linkage(event, &state)?; - - match event { - Event::Rot(rot) => validate_rotation(rot, event, expected_seq, &mut state)?, - Event::Ixn(ixn) => validate_interaction(ixn, event, expected_seq, &mut state)?, - Event::Icp(_) => return Err(ValidationError::MultipleInceptions), - } - } - - Ok(state) -} - -fn parse_threshold(raw: &str) -> Result { - raw.parse::() - .map_err(|_| ValidationError::MalformedSequence { - raw: raw.to_string(), - }) -} - -fn validate_inception(icp: &IcpEvent) -> Result { - verify_event_signature( - &Event::Icp(icp.clone()), - icp.k - .first() - .ok_or(ValidationError::SignatureFailed { sequence: 0 })?, - )?; - - let threshold = parse_threshold(&icp.kt)?; - let next_threshold = parse_threshold(&icp.nt)?; - - Ok(KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - icp.n.clone(), - threshold, - next_threshold, - icp.d.clone(), - )) -} - -fn verify_sequence(event: &Event, expected: u64) -> Result<(), ValidationError> { - let actual = event.sequence().value(); - if actual != expected { - return Err(ValidationError::InvalidSequence { expected, actual }); - } - Ok(()) -} - -fn verify_chain_linkage(event: &Event, state: &KeyState) -> Result<(), ValidationError> { - let prev_said = event.previous().ok_or(ValidationError::NotInception)?; - if *prev_said != state.last_event_said { - return Err(ValidationError::BrokenChain { - sequence: event.sequence().value(), - referenced: prev_said.clone(), - actual: state.last_event_said.clone(), - }); - } - Ok(()) -} - -fn validate_rotation( - rot: &RotEvent, - event: &Event, - sequence: u64, - state: &mut KeyState, -) -> Result<(), ValidationError> { - if !rot.k.is_empty() { - verify_event_signature(event, &rot.k[0])?; - } - - if !state.next_commitment.is_empty() && !rot.k.is_empty() { - let key_bytes = KeriPublicKey::parse(&rot.k[0]) - .map(|k| k.as_bytes().to_vec()) - .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; - - if !verify_commitment(&key_bytes, &state.next_commitment[0]) { - return Err(ValidationError::CommitmentMismatch { sequence }); - } - } - - let threshold = parse_threshold(&rot.kt)?; - let next_threshold = parse_threshold(&rot.nt)?; - - state.apply_rotation( - rot.k.clone(), - rot.n.clone(), - threshold, - next_threshold, - sequence, - rot.d.clone(), - ); - - Ok(()) -} - -fn validate_interaction( - ixn: &IxnEvent, - event: &Event, - sequence: u64, - state: &mut KeyState, -) -> Result<(), ValidationError> { - let current_key = state - .current_key() - .ok_or(ValidationError::SignatureFailed { sequence })?; - verify_event_signature(event, current_key)?; - state.apply_interaction(sequence, ixn.d.clone()); - Ok(()) -} - -/// Replay a KEL to get the current KeyState. -/// -/// This is an alias for [`validate_kel`] and shares all its pure function guarantees. -/// Use whichever name is more semantically appropriate for your context: -/// - `validate_kel` when emphasis is on validation -/// - `replay_kel` when emphasis is on state derivation -pub fn replay_kel(events: &[Event]) -> Result { - validate_kel(events) -} - -/// Validate the cryptographic integrity of a single event against the current key state. -/// -/// This is an O(1) operation — it verifies only the incoming event's signature -/// and pre-rotation commitment against the cached tip state, avoiding full KEL replay. -/// -/// For inception events, `current_state` should be `None`. The function verifies -/// the self-signing property (signature by declared key `k[0]`) and that `i == d`. -/// -/// # Pure Function Guarantees -/// -/// - **Deterministic**: Same inputs always produce same result -/// - **No I/O**: No filesystem, network, or global state access -/// - **O(1)**: Constant-time relative to KEL length -pub fn verify_event_crypto( - event: &Event, - current_state: Option<&KeyState>, -) -> Result<(), ValidationError> { - match event { - Event::Icp(icp) => { - // Inception: verify self-signed with declared key k[0] - let key = icp - .k - .first() - .ok_or(ValidationError::SignatureFailed { sequence: 0 })?; - verify_event_signature(event, key)?; - - // Verify self-certifying identifier: i == d - if icp.i.as_str() != icp.d.as_str() { - return Err(ValidationError::InvalidSaid { - expected: icp.d.clone(), - actual: Said::new_unchecked(icp.i.as_str().to_string()), - }); - } - - Ok(()) - } - Event::Rot(rot) => { - let sequence = event.sequence().value(); - let state = current_state.ok_or(ValidationError::SignatureFailed { sequence })?; - - // Reject rotation on abandoned identity (empty next commitment) - if state.is_abandoned || state.next_commitment.is_empty() { - return Err(ValidationError::CommitmentMismatch { sequence }); - } - - // Rotation is signed by the NEW key - if rot.k.is_empty() { - return Err(ValidationError::SignatureFailed { sequence }); - } - verify_event_signature(event, &rot.k[0])?; - - // Verify pre-rotation commitment: blake3(new_key) == current_state.next_commitment - let key_str = &rot.k[0]; - let key_bytes = KeriPublicKey::parse(key_str) - .map(|k| k.as_bytes().to_vec()) - .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; - - if !verify_commitment(&key_bytes, &state.next_commitment[0]) { - return Err(ValidationError::CommitmentMismatch { sequence }); - } - - Ok(()) - } - Event::Ixn(_) => { - let sequence = event.sequence().value(); - let state = current_state.ok_or(ValidationError::SignatureFailed { sequence })?; - - // Interaction: signed by current key from cached state - let current_key = state - .current_key() - .ok_or(ValidationError::SignatureFailed { sequence })?; - verify_event_signature(event, current_key)?; - - Ok(()) - } - } -} - -/// Verify an event's SAID matches its content hash. -/// -/// The SAID is computed by hashing the event JSON with the `d` field cleared. -pub fn verify_event_said(event: &Event) -> Result<(), ValidationError> { - // Serialize event without the 'd' field for hashing - let json = serialize_for_said(event)?; - let computed = compute_said(&json); - let actual = event.said(); - - if computed != actual.as_str() { - return Err(ValidationError::InvalidSaid { - expected: computed, - actual: actual.clone(), - }); - } - - Ok(()) -} - -/// Validate a single event for appending to a KEL with known state. -/// -/// Checks all invariants that `validate_kel` checks per-event: -/// SAID integrity, sequence continuity, chain linkage, and cryptographic -/// validity (signature + pre-rotation commitment). -/// -/// Args: -/// * `event` - The event to validate for append. -/// * `state` - The current KeyState (tip of the existing KEL). -pub fn validate_for_append(event: &Event, state: &KeyState) -> Result<(), ValidationError> { - if matches!(event, Event::Icp(_)) { - return Err(ValidationError::MultipleInceptions); - } - - verify_event_said(event)?; - verify_sequence(event, state.sequence + 1)?; - verify_chain_linkage(event, state)?; - verify_event_crypto(event, Some(state))?; - - Ok(()) -} - -/// Compute the SAID for an event. -/// -/// This serializes the event with an empty `d` field and computes the Blake3 hash. -pub fn compute_event_said(event: &Event) -> Result { - let json = serialize_for_said(event)?; - Ok(compute_said(&json)) -} - -/// Serialize an event for SAID computation (with empty `d` and `x` fields). -/// For inception events, also clears `i` since it's set to the SAID. -/// The `x` field is cleared because SAID is computed before signature. -fn serialize_for_said(event: &Event) -> Result, ValidationError> { - match event { - Event::Icp(e) => { - let mut e = e.clone(); - e.d = Said::default(); - e.i = Prefix::default(); // For inception, `i` equals `d` - e.x = String::new(); // SAID computed before signature - serde_json::to_vec(&Event::Icp(e)) - } - Event::Rot(e) => { - let mut e = e.clone(); - e.d = Said::default(); - e.x = String::new(); // SAID computed before signature - serde_json::to_vec(&Event::Rot(e)) - } - Event::Ixn(e) => { - let mut e = e.clone(); - e.d = Said::default(); - e.x = String::new(); // SAID computed before signature - serde_json::to_vec(&Event::Ixn(e)) - } - } - .map_err(|e| ValidationError::Serialization(e.to_string())) -} - -/// Serialize event for signing (clears d, i for icp, and x fields). -/// -/// This produces the canonical form over which signatures are computed. -/// Both SAID and signature are computed over this form to avoid circular dependencies. -pub fn serialize_for_signing(event: &Event) -> Result, ValidationError> { - match event { - Event::Icp(e) => { - let mut e = e.clone(); - e.d = Said::default(); - e.i = Prefix::default(); // For inception, `i` equals `d` - e.x = String::new(); - serde_json::to_vec(&Event::Icp(e)) - } - Event::Rot(e) => { - let mut e = e.clone(); - e.d = Said::default(); - e.x = String::new(); - serde_json::to_vec(&Event::Rot(e)) - } - Event::Ixn(e) => { - let mut e = e.clone(); - e.d = Said::default(); - e.x = String::new(); - serde_json::to_vec(&Event::Ixn(e)) - } - } - .map_err(|e| ValidationError::Serialization(e.to_string())) -} - -/// Verify an event's signature using the specified key. -fn verify_event_signature(event: &Event, signing_key: &str) -> Result<(), ValidationError> { - let sequence = event.sequence().value(); - - // Decode the signature - let sig_str = event.signature(); - if sig_str.is_empty() { - return Err(ValidationError::SignatureFailed { sequence }); - } - let sig_bytes = URL_SAFE_NO_PAD - .decode(sig_str) - .map_err(|_| ValidationError::SignatureFailed { sequence })?; - - // Decode the signing key - let key_bytes = KeriPublicKey::parse(signing_key) - .map_err(|_| ValidationError::SignatureFailed { sequence })?; - - // Serialize the event for verification - let canonical = serialize_for_signing(event)?; - - // Verify the signature - let pk = UnparsedPublicKey::new(&ring::signature::ED25519, key_bytes.as_bytes()); - pk.verify(&canonical, &sig_bytes) - .map_err(|_| ValidationError::SignatureFailed { sequence })?; - - Ok(()) -} - -/// Create an inception event with a properly computed SAID. -pub fn finalize_icp_event(mut icp: IcpEvent) -> Result { - // Clear SAID for hashing - icp.d = Said::default(); - icp.i = Prefix::default(); - - // Compute SAID - let json = serde_json::to_vec(&Event::Icp(icp.clone())) - .map_err(|e| ValidationError::Serialization(e.to_string()))?; - let said = compute_said(&json); - - // Set SAID and prefix (same for inception) - icp.d = said.clone(); - icp.i = Prefix::new_unchecked(said.into_inner()); - - Ok(icp) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::keri::{IxnEvent, KERI_VERSION, KeriSequence, Prefix, RotEvent, Said, Seal}; - use base64::Engine; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use ring::rand::SystemRandom; - use ring::signature::{Ed25519KeyPair, KeyPair}; - - fn make_raw_icp(key: &str, next: &str) -> IcpEvent { - IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::default(), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key.to_string()], - nt: "1".to_string(), - n: vec![next.to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - } - } - - /// Create a signed ICP event for testing - fn make_signed_icp() -> (IcpEvent, Ed25519KeyPair) { - let rng = SystemRandom::new(); - let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); - - let icp = IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::default(), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), - n: vec!["ENextCommitment".to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - - // Finalize SAID - let mut finalized = finalize_icp_event(icp).unwrap(); - - // Sign - let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap(); - let sig = keypair.sign(&canonical); - finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - (finalized, keypair) - } - - /// Create a signed IXN event for testing - fn make_signed_ixn( - prefix: &Prefix, - prev_said: &Said, - seq: u64, - keypair: &Ed25519KeyPair, - ) -> IxnEvent { - let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: prefix.clone(), - s: KeriSequence::new(seq), - p: prev_said.clone(), - a: vec![Seal::device_attestation("EAttest")], - x: String::new(), - }; - - // Compute SAID - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); - - // Sign - let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); - let sig = keypair.sign(&canonical); - ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - ixn - } - - #[test] - fn finalize_icp_sets_said() { - let icp = make_raw_icp("DKey1", "ENext1"); - let finalized = finalize_icp_event(icp).unwrap(); - - // SAID should be set and match prefix - assert!(!finalized.d.is_empty()); - assert_eq!(finalized.d.as_str(), finalized.i.as_str()); - assert!(finalized.d.as_str().starts_with('E')); - } - - #[test] - fn validates_single_inception() { - let (icp, _keypair) = make_signed_icp(); - let events = vec![Event::Icp(icp.clone())]; - - let state = validate_kel(&events).unwrap(); - assert_eq!(state.prefix, icp.i); - assert_eq!(state.sequence, 0); - } - - #[test] - fn rejects_empty_kel() { - let result = validate_kel(&[]); - assert!(matches!(result, Err(ValidationError::EmptyKel))); - } - - #[test] - fn rejects_non_inception_first() { - let ixn = IxnEvent { - v: KERI_VERSION.to_string(), - d: Said::new_unchecked("ETest".to_string()), - i: Prefix::new_unchecked("ETest".to_string()), - s: KeriSequence::new(0), - p: Said::new_unchecked("EPrev".to_string()), - a: vec![], - x: String::new(), - }; - let events = vec![Event::Ixn(ixn)]; - let result = validate_kel(&events); - assert!(matches!(result, Err(ValidationError::NotInception))); - } - - #[test] - fn rejects_broken_sequence() { - let (icp, keypair) = make_signed_icp(); - - // Create IXN with wrong sequence (but still properly signed) - let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: icp.i.clone(), - s: KeriSequence::new(5), // Wrong! Should be 1 - p: icp.d.clone(), - a: vec![], - x: String::new(), - }; - - // Compute SAID for IXN - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); - - // Sign with current key - let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); - let sig = keypair.sign(&canonical); - ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let events = vec![Event::Icp(icp), Event::Ixn(ixn)]; - let result = validate_kel(&events); - assert!(matches!( - result, - Err(ValidationError::InvalidSequence { - expected: 1, - actual: 5 - }) - )); - } - - #[test] - fn rejects_broken_chain() { - let (icp, keypair) = make_signed_icp(); - - // Create IXN with wrong previous SAID (but still properly signed) - let mut ixn = IxnEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: icp.i.clone(), - s: KeriSequence::new(1), - p: Said::new_unchecked("EWrongPrevious".to_string()), - a: vec![], - x: String::new(), - }; - - // Compute SAID for IXN - let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); - - // Sign with current key - let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); - let sig = keypair.sign(&canonical); - ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let events = vec![Event::Icp(icp), Event::Ixn(ixn)]; - let result = validate_kel(&events); - assert!(matches!(result, Err(ValidationError::BrokenChain { .. }))); - } - - #[test] - fn rejects_invalid_said() { - let icp = make_raw_icp("DKey1", "ENext1"); - let finalized = finalize_icp_event(icp.clone()).unwrap(); - - // Tamper with the SAID - let mut tampered = finalized.clone(); - tampered.d = Said::new_unchecked("EWrongSaid".to_string()); - - let events = vec![Event::Icp(tampered)]; - let result = validate_kel(&events); - assert!(matches!(result, Err(ValidationError::InvalidSaid { .. }))); - } - - #[test] - fn validates_icp_then_ixn() { - let (icp, keypair) = make_signed_icp(); - - // Create valid signed IXN - let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); - - let events = vec![Event::Icp(icp), Event::Ixn(ixn.clone())]; - let state = validate_kel(&events).unwrap(); - assert_eq!(state.sequence, 1); - assert_eq!(state.last_event_said, ixn.d); - } - - #[test] - fn rejects_multiple_inceptions() { - let icp1 = finalize_icp_event(make_raw_icp("DKey1", "ENext1")).unwrap(); - let icp2 = finalize_icp_event(make_raw_icp("DKey2", "ENext2")).unwrap(); - - let events = vec![Event::Icp(icp1), Event::Icp(icp2)]; - let result = validate_kel(&events); - // Will fail on SAID or sequence validation before multiple inceptions check - assert!(result.is_err()); - } - - #[test] - fn compute_event_said_works() { - let icp = make_raw_icp("DKey1", "ENext1"); - let event = Event::Icp(icp); - let said = compute_event_said(&event).unwrap(); - assert!(said.as_str().starts_with('E')); - assert!(!said.is_empty()); - } - - #[test] - fn rejects_forged_signature() { - let (mut icp, _keypair) = make_signed_icp(); - - // Replace with forged signature (fake 64 bytes) - icp.x = URL_SAFE_NO_PAD.encode([0u8; 64]); - - let events = vec![Event::Icp(icp)]; - let result = validate_kel(&events); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 0 }) - )); - } - - #[test] - fn rejects_missing_signature() { - let (mut icp, _keypair) = make_signed_icp(); - - // Clear the signature - icp.x = String::new(); - - let events = vec![Event::Icp(icp)]; - let result = validate_kel(&events); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 0 }) - )); - } - - #[test] - fn rejects_wrong_key_signature() { - // Create ICP with one key but sign with a different key - let rng = SystemRandom::new(); - let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); - - let mut icp = IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::default(), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), - n: vec!["ENextCommitment".to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - - // Finalize SAID - icp = finalize_icp_event(icp).unwrap(); - - // Sign with a DIFFERENT key - let wrong_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let wrong_keypair = Ed25519KeyPair::from_pkcs8(wrong_pkcs8.as_ref()).unwrap(); - let canonical = serialize_for_signing(&Event::Icp(icp.clone())).unwrap(); - let sig = wrong_keypair.sign(&canonical); - icp.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let events = vec![Event::Icp(icp)]; - let result = validate_kel(&events); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 0 }) - )); - } - - // ========================================================================= - // verify_event_crypto tests (O(1) delta validation) - // ========================================================================= - - #[test] - fn crypto_accepts_valid_inception() { - let (icp, _keypair) = make_signed_icp(); - let result = verify_event_crypto(&Event::Icp(icp), None); - assert!(result.is_ok()); - } - - #[test] - fn crypto_rejects_forged_inception_signature() { - let (mut icp, _keypair) = make_signed_icp(); - icp.x = URL_SAFE_NO_PAD.encode([0u8; 64]); - let result = verify_event_crypto(&Event::Icp(icp), None); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 0 }) - )); - } - - #[test] - fn crypto_rejects_inception_with_mismatched_prefix() { - let (mut icp, _keypair) = make_signed_icp(); - // Tamper i so it doesn't match d - icp.i = Prefix::new_unchecked("EWrongPrefix".to_string()); - let result = verify_event_crypto(&Event::Icp(icp), None); - // Signature will fail because canonical form includes the tampered i - assert!(result.is_err()); - } - - #[test] - fn crypto_accepts_valid_interaction() { - let (icp, keypair) = make_signed_icp(); - let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); - - let threshold = icp.kt.parse().unwrap(); - let next_threshold = icp.nt.parse().unwrap(); - let state = KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - icp.n.clone(), - threshold, - next_threshold, - icp.d.clone(), - ); - let result = verify_event_crypto(&Event::Ixn(ixn), Some(&state)); - assert!(result.is_ok()); - } - - #[test] - fn crypto_rejects_interaction_with_forged_signature() { - let (icp, keypair) = make_signed_icp(); - let mut ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); - - // Forge the signature - ixn.x = URL_SAFE_NO_PAD.encode([0u8; 64]); - - let threshold = icp.kt.parse().unwrap(); - let next_threshold = icp.nt.parse().unwrap(); - let state = KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - icp.n.clone(), - threshold, - next_threshold, - icp.d.clone(), - ); - let result = verify_event_crypto(&Event::Ixn(ixn), Some(&state)); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 1 }) - )); - } - - #[test] - fn crypto_rejects_interaction_signed_by_wrong_key() { - let (icp, _keypair) = make_signed_icp(); - - // Sign IXN with a different key - let rng = SystemRandom::new(); - let wrong_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let wrong_keypair = Ed25519KeyPair::from_pkcs8(wrong_pkcs8.as_ref()).unwrap(); - let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &wrong_keypair); - - let threshold = icp.kt.parse().unwrap(); - let next_threshold = icp.nt.parse().unwrap(); - let state = KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - icp.n.clone(), - threshold, - next_threshold, - icp.d.clone(), - ); - let result = verify_event_crypto(&Event::Ixn(ixn), Some(&state)); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 1 }) - )); - } - - #[test] - fn crypto_rejects_rotation_on_abandoned_identity() { - use auths_core::crypto::said::compute_next_commitment; - - let (icp, _keypair) = make_signed_icp(); - - // Create state with empty next_commitment (abandoned) - let threshold = icp.kt.parse().unwrap(); - let next_threshold = icp.nt.parse().unwrap(); - let mut state = KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - icp.n.clone(), - threshold, - next_threshold, - icp.d.clone(), - ); - state.next_commitment = vec![]; - state.is_abandoned = true; - - // Generate a new key for rotation - let rng = SystemRandom::new(); - let new_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let new_keypair = Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap(); - let new_key_encoded = format!( - "D{}", - URL_SAFE_NO_PAD.encode(new_keypair.public_key().as_ref()) - ); - let new_commitment = compute_next_commitment(new_keypair.public_key().as_ref()); - - let mut rot = RotEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: icp.i.clone(), - s: KeriSequence::new(1), - p: icp.d.clone(), - kt: "1".to_string(), - k: vec![new_key_encoded], - nt: "1".to_string(), - n: vec![new_commitment], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - - // Compute SAID - let json = serde_json::to_vec(&Event::Rot(rot.clone())).unwrap(); - rot.d = compute_said(&json); - - // Sign - let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); - let sig = new_keypair.sign(&canonical); - rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let result = verify_event_crypto(&Event::Rot(rot), Some(&state)); - assert!(matches!( - result, - Err(ValidationError::CommitmentMismatch { .. }) - )); - } - - #[test] - fn crypto_rejects_rotation_without_precommitted_key() { - use auths_core::crypto::said::compute_next_commitment; - - let rng = SystemRandom::new(); - - // Create an inception with a known next commitment - let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); - - // Generate the "real" next key to compute a commitment from - let next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let next_keypair = Ed25519KeyPair::from_pkcs8(next_pkcs8.as_ref()).unwrap(); - let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); - - let icp = IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::default(), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded.clone()], - nt: "1".to_string(), - n: vec![next_commitment.clone()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - let mut icp = finalize_icp_event(icp).unwrap(); - let canonical = serialize_for_signing(&Event::Icp(icp.clone())).unwrap(); - let sig = keypair.sign(&canonical); - icp.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let threshold = icp.kt.parse().unwrap(); - let next_threshold = icp.nt.parse().unwrap(); - let state = KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - vec![next_commitment], - threshold, - next_threshold, - icp.d.clone(), - ); - - // Rotate with a RANDOM key that doesn't match the commitment - let wrong_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let wrong_keypair = Ed25519KeyPair::from_pkcs8(wrong_pkcs8.as_ref()).unwrap(); - let wrong_key_encoded = format!( - "D{}", - URL_SAFE_NO_PAD.encode(wrong_keypair.public_key().as_ref()) - ); - let new_commitment = compute_next_commitment(wrong_keypair.public_key().as_ref()); - - let mut rot = RotEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: icp.i.clone(), - s: KeriSequence::new(1), - p: icp.d.clone(), - kt: "1".to_string(), - k: vec![wrong_key_encoded], - nt: "1".to_string(), - n: vec![new_commitment], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - - let json = serde_json::to_vec(&Event::Rot(rot.clone())).unwrap(); - rot.d = compute_said(&json); - let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); - let sig = wrong_keypair.sign(&canonical); - rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let result = verify_event_crypto(&Event::Rot(rot), Some(&state)); - assert!(matches!( - result, - Err(ValidationError::CommitmentMismatch { .. }) - )); - } - - #[test] - fn crypto_accepts_valid_rotation() { - use auths_core::crypto::said::compute_next_commitment; - - let rng = SystemRandom::new(); - - // Create inception with known next commitment - let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); - - // Generate the next key and its commitment - let next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let next_keypair = Ed25519KeyPair::from_pkcs8(next_pkcs8.as_ref()).unwrap(); - let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); - - let icp = IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::default(), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), - n: vec![next_commitment.clone()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - let mut icp = finalize_icp_event(icp).unwrap(); - let canonical = serialize_for_signing(&Event::Icp(icp.clone())).unwrap(); - let sig = keypair.sign(&canonical); - icp.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let threshold = icp.kt.parse().unwrap(); - let next_threshold = icp.nt.parse().unwrap(); - let state = KeyState::from_inception( - icp.i.clone(), - icp.k.clone(), - vec![next_commitment], - threshold, - next_threshold, - icp.d.clone(), - ); - - // Rotate with the CORRECT next key - let next_key_encoded = format!( - "D{}", - URL_SAFE_NO_PAD.encode(next_keypair.public_key().as_ref()) - ); - let third_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let third_keypair = Ed25519KeyPair::from_pkcs8(third_pkcs8.as_ref()).unwrap(); - let third_commitment = compute_next_commitment(third_keypair.public_key().as_ref()); - - let mut rot = RotEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: icp.i.clone(), - s: KeriSequence::new(1), - p: icp.d.clone(), - kt: "1".to_string(), - k: vec![next_key_encoded], - nt: "1".to_string(), - n: vec![third_commitment], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - - let json = serde_json::to_vec(&Event::Rot(rot.clone())).unwrap(); - rot.d = compute_said(&json); - let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); - let sig = next_keypair.sign(&canonical); - rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let result = verify_event_crypto(&Event::Rot(rot), Some(&state)); - assert!(result.is_ok()); - } - - // ========================================================================= - // Extracted helper function tests - // ========================================================================= - - #[test] - fn parse_threshold_valid() { - assert_eq!(parse_threshold("1").unwrap(), 1); - assert_eq!(parse_threshold("42").unwrap(), 42); - assert_eq!(parse_threshold("0").unwrap(), 0); - } - - #[test] - fn parse_threshold_invalid() { - assert!(matches!( - parse_threshold("abc"), - Err(ValidationError::MalformedSequence { .. }) - )); - assert!(matches!( - parse_threshold(""), - Err(ValidationError::MalformedSequence { .. }) - )); - assert!(matches!( - parse_threshold("-1"), - Err(ValidationError::MalformedSequence { .. }) - )); - } - - #[test] - fn validate_inception_success() { - let (icp, _keypair) = make_signed_icp(); - let state = validate_inception(&icp).unwrap(); - assert_eq!(state.prefix, icp.i); - assert_eq!(state.sequence, 0); - assert_eq!(state.last_event_said, icp.d); - } - - #[test] - fn validate_inception_bad_signature() { - let (mut icp, _keypair) = make_signed_icp(); - icp.x = URL_SAFE_NO_PAD.encode([0u8; 64]); - let result = validate_inception(&icp); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 0 }) - )); - } - - #[test] - fn verify_sequence_correct() { - let (icp, keypair) = make_signed_icp(); - let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); - assert!(verify_sequence(&Event::Ixn(ixn), 1).is_ok()); - } - - #[test] - fn verify_sequence_mismatch() { - let (icp, keypair) = make_signed_icp(); - let ixn = make_signed_ixn(&icp.i, &icp.d, 5, &keypair); - let result = verify_sequence(&Event::Ixn(ixn), 1); - assert!(matches!( - result, - Err(ValidationError::InvalidSequence { - expected: 1, - actual: 5 - }) - )); - } - - #[test] - fn verify_chain_linkage_correct() { - let (icp, keypair) = make_signed_icp(); - let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); - let state = validate_inception(&icp).unwrap(); - assert!(verify_chain_linkage(&Event::Ixn(ixn), &state).is_ok()); - } - - #[test] - fn verify_chain_linkage_broken() { - let (icp, keypair) = make_signed_icp(); - let wrong_said = Said::new_unchecked("EWrongPrevious".to_string()); - let ixn = make_signed_ixn(&icp.i, &wrong_said, 1, &keypair); - let state = validate_inception(&icp).unwrap(); - let result = verify_chain_linkage(&Event::Ixn(ixn), &state); - assert!(matches!(result, Err(ValidationError::BrokenChain { .. }))); - } - - #[test] - fn validate_rotation_bad_commitment() { - use auths_core::crypto::said::compute_next_commitment; - - let rng = SystemRandom::new(); - - let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); - let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); - - let next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let next_keypair = Ed25519KeyPair::from_pkcs8(next_pkcs8.as_ref()).unwrap(); - let next_commitment = compute_next_commitment(next_keypair.public_key().as_ref()); - - let icp = IcpEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: Prefix::default(), - s: KeriSequence::new(0), - kt: "1".to_string(), - k: vec![key_encoded], - nt: "1".to_string(), - n: vec![next_commitment], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - let mut icp = finalize_icp_event(icp).unwrap(); - let canonical = serialize_for_signing(&Event::Icp(icp.clone())).unwrap(); - let sig = keypair.sign(&canonical); - icp.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let mut state = validate_inception(&icp).unwrap(); - - // Rotate with a WRONG key that doesn't match the commitment - let wrong_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let wrong_keypair = Ed25519KeyPair::from_pkcs8(wrong_pkcs8.as_ref()).unwrap(); - let wrong_key_encoded = format!( - "D{}", - URL_SAFE_NO_PAD.encode(wrong_keypair.public_key().as_ref()) - ); - let wrong_commitment = compute_next_commitment(wrong_keypair.public_key().as_ref()); - - let mut rot = RotEvent { - v: KERI_VERSION.to_string(), - d: Said::default(), - i: icp.i.clone(), - s: KeriSequence::new(1), - p: icp.d.clone(), - kt: "1".to_string(), - k: vec![wrong_key_encoded], - nt: "1".to_string(), - n: vec![wrong_commitment], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - - let json = serde_json::to_vec(&Event::Rot(rot.clone())).unwrap(); - rot.d = compute_said(&json); - let canonical = serialize_for_signing(&Event::Rot(rot.clone())).unwrap(); - let sig = wrong_keypair.sign(&canonical); - rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let result = validate_rotation(&rot, &Event::Rot(rot.clone()), 1, &mut state); - assert!(matches!( - result, - Err(ValidationError::CommitmentMismatch { sequence: 1 }) - )); - } - - #[test] - fn validate_interaction_wrong_key() { - let (icp, _keypair) = make_signed_icp(); - let mut state = validate_inception(&icp).unwrap(); - - let rng = SystemRandom::new(); - let wrong_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let wrong_keypair = Ed25519KeyPair::from_pkcs8(wrong_pkcs8.as_ref()).unwrap(); - let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &wrong_keypair); - - let result = validate_interaction(&ixn, &Event::Ixn(ixn.clone()), 1, &mut state); - assert!(matches!( - result, - Err(ValidationError::SignatureFailed { sequence: 1 }) - )); - } -} +//! KEL validation re-exported from auths-keri. +pub use auths_keri::{ + ValidationError, compute_event_said, finalize_icp_event, find_seal_in_kel, parse_kel_json, + replay_kel, serialize_for_signing, validate_for_append, validate_kel, verify_event_crypto, + verify_event_said, +}; diff --git a/crates/auths-id/src/policy/mod.rs b/crates/auths-id/src/policy/mod.rs index 3ef9e5ab..ae925c48 100644 --- a/crates/auths-id/src/policy/mod.rs +++ b/crates/auths-id/src/policy/mod.rs @@ -400,9 +400,9 @@ pub fn evaluate_with_receipts( mod tests { use super::*; use auths_core::witness::NoOpWitness; + use auths_keri::{Prefix, Said}; use auths_verifier::AttestationBuilder; use auths_verifier::core::Capability; - use auths_verifier::keri::{Prefix, Said}; use chrono::Duration; /// Mock witness for testing diff --git a/crates/auths-id/src/storage/receipts.rs b/crates/auths-id/src/storage/receipts.rs index 3640db7b..c69b9b1f 100644 --- a/crates/auths-id/src/storage/receipts.rs +++ b/crates/auths-id/src/storage/receipts.rs @@ -255,7 +255,7 @@ pub fn check_receipt_consistency(receipts: &[Receipt]) -> Result<(), StorageErro mod tests { use super::*; use auths_core::witness::{KERI_VERSION, RECEIPT_TYPE, Receipt}; - use auths_verifier::keri::Said; + use auths_keri::Said; use git2::RepositoryInitOptions; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; diff --git a/crates/auths-id/src/testing/fakes/registry.rs b/crates/auths-id/src/testing/fakes/registry.rs index 26f4e16f..aaa6f2f1 100644 --- a/crates/auths-id/src/testing/fakes/registry.rs +++ b/crates/auths-id/src/testing/fakes/registry.rs @@ -3,8 +3,8 @@ use std::ops::ControlFlow; use std::sync::Mutex; use auths_core::storage::keychain::IdentityDID; +use auths_keri::Prefix; use auths_verifier::core::Attestation; -use auths_verifier::keri::Prefix; use auths_verifier::types::{CanonicalDid, DeviceDID}; use chrono::{DateTime, Utc}; diff --git a/crates/auths-id/src/trailer.rs b/crates/auths-id/src/trailer.rs index c482e004..7bfa1ad6 100644 --- a/crates/auths-id/src/trailer.rs +++ b/crates/auths-id/src/trailer.rs @@ -179,7 +179,7 @@ fn parse_trailer_line(line: &str) -> Option<(String, String)> { mod tests { use super::*; use auths_core::witness::{KERI_VERSION, RECEIPT_TYPE}; - use auths_verifier::keri::Said; + use auths_keri::Said; fn sample_receipt() -> Receipt { Receipt { diff --git a/crates/auths-id/src/trust/mod.rs b/crates/auths-id/src/trust/mod.rs index b490029a..aeee9ee7 100644 --- a/crates/auths-id/src/trust/mod.rs +++ b/crates/auths-id/src/trust/mod.rs @@ -4,7 +4,7 @@ //! providing Git-backed KEL replay for verifying rotation continuity. use auths_core::trust::continuity::{KelContinuityChecker, RotationProof}; -use auths_crypto::KeriPublicKey; +use auths_keri::KeriPublicKey; use git2::Repository; use crate::keri::{Event, GitKel, Said, did_to_prefix, validate_kel}; @@ -118,7 +118,7 @@ fn verify_chain_from_index(events: &[Event], pinned_idx: usize, pinned_tip_said: #[cfg(test)] mod tests { - use auths_crypto::KeriPublicKey; + use auths_keri::KeriPublicKey; #[test] fn test_keri_key_parse_valid() { diff --git a/crates/auths-index/Cargo.toml b/crates/auths-index/Cargo.toml index 0a582ddf..ae9c2c4d 100644 --- a/crates/auths-index/Cargo.toml +++ b/crates/auths-index/Cargo.toml @@ -12,6 +12,7 @@ publish = true license.workspace = true [dependencies] +auths-keri.workspace = true auths-verifier.workspace = true sqlite = { version = "0.32", features = ["bundled"] } chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/auths-index/src/index.rs b/crates/auths-index/src/index.rs index 8b5c2add..5e7f908d 100644 --- a/crates/auths-index/src/index.rs +++ b/crates/auths-index/src/index.rs @@ -1,7 +1,7 @@ use crate::error::Result; use crate::schema; +use auths_keri::{Prefix, Said}; use auths_verifier::core::{CommitOid, ResourceId}; -use auths_verifier::keri::{Prefix, Said}; use auths_verifier::types::{CanonicalDid, IdentityDID}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; diff --git a/crates/auths-infra-git/Cargo.toml b/crates/auths-infra-git/Cargo.toml index 97ed2b25..4446f06a 100644 --- a/crates/auths-infra-git/Cargo.toml +++ b/crates/auths-infra-git/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true homepage.workspace = true [dependencies] +auths-keri.workspace = true auths-core = { workspace = true } auths-sdk = { workspace = true } auths-verifier = { workspace = true, features = ["native"] } diff --git a/crates/auths-infra-git/src/event_log.rs b/crates/auths-infra-git/src/event_log.rs index 516c65b7..b891a842 100644 --- a/crates/auths-infra-git/src/event_log.rs +++ b/crates/auths-infra-git/src/event_log.rs @@ -1,5 +1,6 @@ use auths_core::ports::storage::{EventLogReader, EventLogWriter, StorageError}; -use auths_verifier::keri::Prefix; +use auths_keri::Prefix; +use auths_keri::kel_io::KelStorageError; use crate::error::map_git2_error; use crate::helpers; @@ -36,42 +37,62 @@ impl<'r> GitEventLog<'r> { } } +/// Convert a `StorageError` into `KelStorageError` (identical variant set). +fn to_kel(e: StorageError) -> KelStorageError { + match e { + StorageError::NotFound { path } => KelStorageError::NotFound { path }, + StorageError::AlreadyExists { path } => KelStorageError::AlreadyExists { path }, + StorageError::CasConflict => KelStorageError::CasConflict, + StorageError::Io(s) => KelStorageError::Io(s), + StorageError::Internal(e) => KelStorageError::Internal(e), + // #[non_exhaustive]: forward any future variants as internal errors + other => KelStorageError::Internal(Box::new(other)), + } +} + +// auths_core::ports::storage::EventLogReader re-exports auths_keri::kel_io::EventLogReader, +// so these impls satisfy both paths simultaneously. impl EventLogReader for GitEventLog<'_> { - fn read_event_log(&self, prefix: &Prefix) -> Result, StorageError> { + fn read_event_log(&self, prefix: &Prefix) -> Result, KelStorageError> { let refname = Self::kel_ref(prefix.as_str()); - self.repo.with_repo(|repo| { - let events = walk_commits(repo, &refname)?; - let joined: Vec = events.into_iter().flatten().collect(); - Ok(joined) - }) + self.repo + .with_repo(|repo| { + let events = walk_commits(repo, &refname)?; + let joined: Vec = events.into_iter().flatten().collect(); + Ok(joined) + }) + .map_err(to_kel) } - fn read_event_at(&self, prefix: &Prefix, seq: u64) -> Result, StorageError> { + fn read_event_at(&self, prefix: &Prefix, seq: u64) -> Result, KelStorageError> { let refname = Self::kel_ref(prefix.as_str()); - self.repo.with_repo(|repo| { - let events = walk_commits(repo, &refname)?; - events - .into_iter() - .nth(seq as usize) - .ok_or_else(|| StorageError::not_found(format!("{}/seq/{}", prefix.as_str(), seq))) - }) + self.repo + .with_repo(|repo| { + let events = walk_commits(repo, &refname)?; + events.into_iter().nth(seq as usize).ok_or_else(|| { + StorageError::not_found(format!("{}/seq/{}", prefix.as_str(), seq)) + }) + }) + .map_err(to_kel) } } impl EventLogWriter for GitEventLog<'_> { - fn append_event(&self, prefix: &Prefix, event: &[u8]) -> Result<(), StorageError> { + fn append_event(&self, prefix: &Prefix, event: &[u8]) -> Result<(), KelStorageError> { let refname = Self::kel_ref(prefix.as_str()); - self.repo.with_repo(|repo| { - helpers::create_ref_commit( - repo, - &refname, - event, - EVENT_FILE, - &format!("append event to {}", prefix.as_str()), - ) - .map_err(map_git2_error)?; - Ok(()) - }) + self.repo + .with_repo(|repo| { + helpers::create_ref_commit( + repo, + &refname, + event, + EVENT_FILE, + &format!("append event to {}", prefix.as_str()), + ) + .map_err(map_git2_error)?; + Ok(()) + }) + .map_err(to_kel) } } diff --git a/crates/auths-infra-git/tests/cases/event_log.rs b/crates/auths-infra-git/tests/cases/event_log.rs index 99ccff82..2576eb9e 100644 --- a/crates/auths-infra-git/tests/cases/event_log.rs +++ b/crates/auths-infra-git/tests/cases/event_log.rs @@ -1,6 +1,7 @@ -use auths_core::ports::storage::{EventLogReader, EventLogWriter, StorageError}; +use auths_core::ports::storage::{EventLogReader, EventLogWriter}; use auths_infra_git::{GitEventLog, GitRepo}; -use auths_verifier::keri::Prefix; +use auths_keri::Prefix; +use auths_keri::kel_io::KelStorageError; fn setup() -> (tempfile::TempDir, GitRepo) { let (dir, _repo) = auths_test_utils::git::init_test_repo(); @@ -45,7 +46,7 @@ fn read_event_at_out_of_range() { log.append_event(&prefix, b"only-one").unwrap(); let result = log.read_event_at(&prefix, 5); - assert!(matches!(result, Err(StorageError::NotFound { .. }))); + assert!(matches!(result, Err(KelStorageError::NotFound { .. }))); } #[test] diff --git a/crates/auths-infra-http/Cargo.toml b/crates/auths-infra-http/Cargo.toml index d5b5afa9..a1e2c48c 100644 --- a/crates/auths-infra-http/Cargo.toml +++ b/crates/auths-infra-http/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true homepage.workspace = true [dependencies] +auths-keri.workspace = true async-trait = "0.1" auths-core = { workspace = true } auths-crypto = { workspace = true } diff --git a/crates/auths-infra-http/src/async_witness_client.rs b/crates/auths-infra-http/src/async_witness_client.rs index 8be80044..2f88aedc 100644 --- a/crates/auths-infra-http/src/async_witness_client.rs +++ b/crates/auths-infra-http/src/async_witness_client.rs @@ -7,7 +7,7 @@ use crate::default_client_builder; use auths_core::witness::{ AsyncWitnessProvider, DuplicityEvidence, EventHash, Receipt, WitnessError, }; -use auths_verifier::keri::{Prefix, Said}; +use auths_keri::{Prefix, Said}; /// HTTP-based witness client implementing [`AsyncWitnessProvider`]. /// diff --git a/crates/auths-infra-http/src/witness_client.rs b/crates/auths-infra-http/src/witness_client.rs index 0e7771cb..87f4c3ed 100644 --- a/crates/auths-infra-http/src/witness_client.rs +++ b/crates/auths-infra-http/src/witness_client.rs @@ -1,5 +1,5 @@ use auths_core::ports::network::{NetworkError, WitnessClient}; -use auths_verifier::keri::Prefix; +use auths_keri::Prefix; use std::future::Future; use std::time::Duration; diff --git a/crates/auths-infra-http/tests/cases/witness.rs b/crates/auths-infra-http/tests/cases/witness.rs index 7913468a..b8ab74e0 100644 --- a/crates/auths-infra-http/tests/cases/witness.rs +++ b/crates/auths-infra-http/tests/cases/witness.rs @@ -6,7 +6,7 @@ use auths_core::witness::{ AsyncWitnessProvider, ReceiptCollectorBuilder, WitnessError, WitnessServerState, }; use auths_infra_http::HttpAsyncWitnessClient; -use auths_verifier::keri::{Prefix, Said}; +use auths_keri::{Prefix, Said}; use ring::signature::{Ed25519KeyPair, KeyPair}; async fn start_test_server() -> (SocketAddr, WitnessServerState) { diff --git a/crates/auths-keri/Cargo.toml b/crates/auths-keri/Cargo.toml index 4c5afb4c..aebd9339 100644 --- a/crates/auths-keri/Cargo.toml +++ b/crates/auths-keri/Cargo.toml @@ -4,29 +4,34 @@ version.workspace = true edition = "2024" publish = true license.workspace = true -description = "KERI CESR translation layer for Auths" +description = "KERI protocol types, SAID computation, and validation" keywords = ["keri", "cesr", "identity", "cryptography"] categories = ["cryptography", "encoding"] repository.workspace = true homepage.workspace = true [dependencies] +async-trait = "0.1" auths-crypto.workspace = true -auths-verifier = { workspace = true, features = ["native"] } base64.workspace = true blake3 = "1.5" -cesride = "0.6" -hex = "0.4.3" +cesride = { version = "0.6", optional = true } +hex = { version = "0.4.3", features = ["serde"] } +ring.workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = { workspace = true, optional = true } +subtle.workspace = true thiserror.workspace = true [features] -schema = ["dep:schemars", "auths-verifier/schema"] +default = [] +cesr = ["dep:cesride"] +schema = ["dep:schemars"] [dev-dependencies] proptest = "1.4" +tokio = { workspace = true } [lints] workspace = true diff --git a/crates/auths-keri/fuzz/Cargo.toml b/crates/auths-keri/fuzz/Cargo.toml index 6673be86..5aabba18 100644 --- a/crates/auths-keri/fuzz/Cargo.toml +++ b/crates/auths-keri/fuzz/Cargo.toml @@ -13,6 +13,7 @@ libfuzzer-sys = "0.4" [dependencies.auths_keri] package = "auths-keri" path = ".." +features = ["cesr"] # Prevent this from interfering with workspaces [workspace] diff --git a/crates/auths-keri/src/crypto.rs b/crates/auths-keri/src/crypto.rs new file mode 100644 index 00000000..f3aeb650 --- /dev/null +++ b/crates/auths-keri/src/crypto.rs @@ -0,0 +1,151 @@ +//! Internal KERI SAID computation and key commitment functions. +//! +//! This module implements the internal SAID algorithm (empty `d` field, not +//! the spec-compliant `###` placeholder). See `said.rs` for the spec-compliant +//! variant used in CESR interop. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use subtle::ConstantTimeEq; + +use crate::types::Said; + +/// Compute SAID (Self-Addressing Identifier) for a KERI event. +/// +/// The SAID is computed by: +/// 1. Hashing the input with Blake3 +/// 2. Encoding the hash as Base64url (no padding) +/// 3. Prefixing with 'E' (KERI derivation code for Blake3-256) +/// +/// # Arguments +/// * `event_json` - The canonical JSON bytes of the event (with 'd' field empty) +/// +/// # Returns +/// A `Said` wrapping a string like "EXq5YqaL6L48pf0fu7IUhL0JRaU2_RxFP0AL43wYn148" +/// +/// # Fixed-Input Regression +/// +/// The SAID algorithm is deterministic. This doc-test pins the output for a +/// known input. If this test fails, the SAID algorithm has changed — do NOT +/// update the expected value without a migration plan for stored events. +/// +/// ``` +/// use auths_keri::compute_said; +/// let said = compute_said(b"{\"t\":\"icp\",\"s\":\"0\"}"); +/// // The exact value is pinned to the blake3 hash of the input above. +/// assert_eq!(said.as_str().len(), 44); +/// assert!(said.as_str().starts_with('E')); +/// // Verify determinism (same input = same output) +/// assert_eq!(said, compute_said(b"{\"t\":\"icp\",\"s\":\"0\"}")); +/// ``` +pub fn compute_said(event_json: &[u8]) -> Said { + let hash = blake3::hash(event_json); + let encoded = URL_SAFE_NO_PAD.encode(hash.as_bytes()); + Said::new_unchecked(format!("E{}", encoded)) +} + +/// Compute next-key commitment hash for pre-rotation. +/// +/// The commitment is computed by: +/// 1. Hashing the public key bytes with Blake3 +/// 2. Encoding the hash as Base64url (no padding) +/// 3. Prefixing with 'E' (KERI derivation code for Blake3-256) +/// +/// This commitment is included in the current event's 'n' field and must +/// be satisfied by the next rotation event's 'k' field. +/// +/// # Arguments +/// * `public_key` - The raw public key bytes (32 bytes for Ed25519) +/// +/// # Returns +/// A commitment string like "EO8CE5RH3wHBrXyFay3MOXq5YqaL6L48pf0fu7IUhL0J" +pub fn compute_next_commitment(public_key: &[u8]) -> String { + let hash = blake3::hash(public_key); + let encoded = URL_SAFE_NO_PAD.encode(hash.as_bytes()); + format!("E{}", encoded) +} + +/// Verify that a public key matches a commitment. +/// +/// # Arguments +/// * `public_key` - The raw public key bytes to verify +/// * `commitment` - The commitment string from a previous event's 'n' field +/// +/// # Returns +/// `true` if the public key hashes to the commitment, `false` otherwise +// Defense-in-depth: both values are derived from public data, but constant-time +// comparison prevents timing side-channels on commitment verification. +pub fn verify_commitment(public_key: &[u8], commitment: &str) -> bool { + let computed = compute_next_commitment(public_key); + computed.as_bytes().ct_eq(commitment.as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixed-input regression test for the internal SAID algorithm. + /// + /// If this hash ever changes, the on-disk event format has changed and + /// stored events will no longer verify correctly. + #[test] + fn said_regression_fixed_input() { + let json = b"{\"t\":\"icp\",\"d\":\"\"}"; + let said = compute_said(json); + // This value is the canonical output of blake3(json) + base64url + 'E'. + // Do NOT change this value without a migration plan for stored events. + assert_eq!(said.as_str().len(), 44); + assert!(said.as_str().starts_with('E')); + // Verify determinism + let said2 = compute_said(json); + assert_eq!(said, said2); + } + + #[test] + fn said_is_deterministic() { + let json = b"{\"t\":\"icp\",\"s\":\"0\"}"; + let said1 = compute_said(json); + let said2 = compute_said(json); + assert_eq!(said1, said2); + assert!(said1.as_str().starts_with('E')); + } + + #[test] + fn said_has_correct_length() { + let json = b"{\"test\":\"data\"}"; + let said = compute_said(json); + // 'E' + 43 chars of base64url (32 bytes encoded) + assert_eq!(said.as_str().len(), 44); + } + + #[test] + fn different_inputs_produce_different_saids() { + let said1 = compute_said(b"{\"a\":1}"); + let said2 = compute_said(b"{\"a\":2}"); + assert_ne!(said1, said2); + } + + #[test] + fn commitment_verification_works() { + let key = [1u8; 32]; + let commitment = compute_next_commitment(&key); + assert!(verify_commitment(&key, &commitment)); + assert!(!verify_commitment(&[2u8; 32], &commitment)); + } + + #[test] + fn commitment_is_deterministic() { + let key = [42u8; 32]; + let c1 = compute_next_commitment(&key); + let c2 = compute_next_commitment(&key); + assert_eq!(c1, c2); + assert!(c1.starts_with('E')); + } + + #[test] + fn commitment_has_correct_length() { + let key = [0u8; 32]; + let commitment = compute_next_commitment(&key); + // 'E' + 43 chars of base64url + assert_eq!(commitment.len(), 44); + } +} diff --git a/crates/auths-keri/src/event.rs b/crates/auths-keri/src/event.rs index 9fb4d38f..1883678f 100644 --- a/crates/auths-keri/src/event.rs +++ b/crates/auths-keri/src/event.rs @@ -1,4 +1,3 @@ -use auths_verifier::keri::{KeriEvent, Prefix, Said}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -23,7 +22,7 @@ pub struct SerializedEvent { pub signature_key_index: u32, } -/// Converts an Auths internal event into a spec-compliant serialized event. +/// Converts a KERI event JSON value into a spec-compliant serialized event. /// /// Extracts the `x` signature (base64url-no-pad), removes it from the body, /// computes the version string and SAID per spec, and returns the serialized @@ -31,23 +30,28 @@ pub struct SerializedEvent { /// /// Args: /// * `codec`: The CESR codec. -/// * `event`: The internal Auths event. +/// * `event`: The KERI event as a JSON object. /// /// Usage: /// ```ignore -/// let serialized = serialize_for_cesr(&codec, &keri_event)?; +/// let serialized = serialize_for_cesr(&codec, &event_json)?; /// ``` pub fn serialize_for_cesr( codec: &dyn CesrCodec, - event: &KeriEvent, + event: &serde_json::Value, ) -> Result { - let mut event = event.clone(); - - let sig_str = match &event { - KeriEvent::Inception(e) => e.x.clone(), - KeriEvent::Rotation(e) => e.x.clone(), - KeriEvent::Interaction(e) => e.x.clone(), - }; + let obj = event + .as_object() + .ok_or(KeriTranslationError::MissingField { + field: "root object", + })?; + + // Extract signature before clearing it. + let sig_str = obj + .get("x") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); let signature_bytes = if sig_str.is_empty() { None } else { @@ -56,53 +60,48 @@ pub fn serialize_for_cesr( })?) }; - // Clear x and set d/i to placeholders. - match &mut event { - KeriEvent::Inception(icp) => { - icp.x = String::new(); - icp.d = Said::new_unchecked(SAID_PLACEHOLDER.to_string()); - icp.i = Prefix::new_unchecked(SAID_PLACEHOLDER.to_string()); - } - KeriEvent::Rotation(rot) => { - rot.x = String::new(); - rot.d = Said::new_unchecked(SAID_PLACEHOLDER.to_string()); - } - KeriEvent::Interaction(ixn) => { - ixn.x = String::new(); - ixn.d = Said::new_unchecked(SAID_PLACEHOLDER.to_string()); - } + // Build mutable copy, clear x, insert d/i placeholders. + let mut obj = obj.clone(); + obj.remove("x"); + obj.insert( + "d".to_string(), + serde_json::Value::String(SAID_PLACEHOLDER.to_string()), + ); + + let event_type = obj + .get("t") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if event_type == "icp" { + obj.insert( + "i".to_string(), + serde_json::Value::String(SAID_PLACEHOLDER.to_string()), + ); } - // Serialize with placeholders → measure byte count → version string. + // Measure byte count → version string. // Placeholder "000000" and hex size are both 6 chars, so byte count is stable. - let placeholder_bytes = - serde_json::to_vec(&event).map_err(KeriTranslationError::SerializationFailed)?; + let placeholder_bytes = serde_json::to_vec(&serde_json::Value::Object(obj.clone())) + .map_err(KeriTranslationError::SerializationFailed)?; let size = placeholder_bytes.len(); let version_string = format!("KERI10JSON{size:06x}_"); - - set_version(&mut event, &version_string); + obj.insert("v".to_string(), serde_json::Value::String(version_string)); // Serialize with version string + placeholders → hash → SAID. - let for_said = serde_json::to_vec(&event).map_err(KeriTranslationError::SerializationFailed)?; + let for_said = serde_json::to_vec(&serde_json::Value::Object(obj.clone())) + .map_err(KeriTranslationError::SerializationFailed)?; let hash = blake3::hash(&for_said); let said = codec.encode_digest(hash.as_bytes(), DigestType::Blake3_256)?; // Replace placeholders with computed SAID (same 44-char length → stable byte count). - match &mut event { - KeriEvent::Inception(icp) => { - icp.d = Said::new_unchecked(said.clone()); - icp.i = Prefix::new_unchecked(said.clone()); - } - KeriEvent::Rotation(rot) => { - rot.d = Said::new_unchecked(said.clone()); - } - KeriEvent::Interaction(ixn) => { - ixn.d = Said::new_unchecked(said.clone()); - } + obj.insert("d".to_string(), serde_json::Value::String(said.clone())); + if event_type == "icp" { + obj.insert("i".to_string(), serde_json::Value::String(said.clone())); } - let body_bytes = - serde_json::to_vec(&event).map_err(KeriTranslationError::SerializationFailed)?; + let body_bytes = serde_json::to_vec(&serde_json::Value::Object(obj)) + .map_err(KeriTranslationError::SerializationFailed)?; Ok(SerializedEvent { body_bytes, @@ -112,14 +111,6 @@ pub fn serialize_for_cesr( }) } -fn set_version(event: &mut KeriEvent, version: &str) { - match event { - KeriEvent::Inception(icp) => icp.v = version.to_string(), - KeriEvent::Rotation(rot) => rot.v = version.to_string(), - KeriEvent::Interaction(ixn) => ixn.v = version.to_string(), - } -} - /// Re-encodes a CESR-qualified key back to raw bytes for internal use. /// /// Args: diff --git a/crates/auths-keri/src/events.rs b/crates/auths-keri/src/events.rs new file mode 100644 index 00000000..d2a8822e --- /dev/null +++ b/crates/auths-keri/src/events.rs @@ -0,0 +1,557 @@ +//! Canonical KERI event types: Inception (ICP), Rotation (ROT), Interaction (IXN). +//! +//! These types are the single authoritative definition of KERI events for the +//! entire workspace. All other crates import from here. + +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize, Serializer}; +use std::fmt; + +use crate::types::{Prefix, Said}; + +/// KERI protocol version prefix string. +pub const KERI_VERSION: &str = "KERI10JSON"; + +// ── KeriSequence ───────────────────────────────────────────────────────────── + +/// A KERI sequence number, stored internally as u64 and serialized as a hex string. +/// +/// Sequence numbers are spec-compliant hex strings: "0", "1", "a", "ff", etc. +/// +/// Usage: +/// ```ignore +/// let seq = KeriSequence::new(10); +/// assert_eq!(seq.value(), 10); +/// assert_eq!(serde_json::to_string(&seq).unwrap(), "\"a\""); +/// ``` +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct KeriSequence(u64); + +#[cfg(feature = "schema")] +impl schemars::JsonSchema for KeriSequence { + fn schema_name() -> String { + "KeriSequence".to_string() + } + + fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + ..Default::default() + } + .into() + } +} + +impl KeriSequence { + /// Create a new sequence number. + pub fn new(value: u64) -> Self { + Self(value) + } + + /// Return the inner u64 value. + pub fn value(self) -> u64 { + self.0 + } +} + +impl fmt::Display for KeriSequence { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:x}", self.0) + } +} + +impl Serialize for KeriSequence { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&format!("{:x}", self.0)) + } +} + +impl<'de> Deserialize<'de> for KeriSequence { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + let value = u64::from_str_radix(&s, 16) + .map_err(|_| serde::de::Error::custom(format!("invalid hex sequence: {s:?}")))?; + Ok(KeriSequence(value)) + } +} + +// ── Seal ───────────────────────────────────────────────────────────────────── + +/// Type of data anchored by a seal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum SealType { + /// Device attestation seal + DeviceAttestation, + /// Revocation seal + Revocation, + /// Capability delegation seal + Delegation, + /// Identity provider binding seal + IdpBinding, +} + +impl fmt::Display for SealType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SealType::DeviceAttestation => write!(f, "device-attestation"), + SealType::Revocation => write!(f, "revocation"), + SealType::Delegation => write!(f, "delegation"), + SealType::IdpBinding => write!(f, "idp-binding"), + } + } +} + +/// A seal anchors external data in a KERI event. +/// +/// Seals are included in the `a` (anchors) field of KERI events. +/// They contain a digest of the anchored data and a type indicator. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct Seal { + /// SAID (digest) of the anchored data + pub d: Said, + /// Type of anchored data + #[serde(rename = "type")] + pub seal_type: SealType, +} + +impl Seal { + /// Create a new seal with the given digest and type. + /// + /// Args: + /// * `digest`: SAID of the anchored data. + /// * `seal_type`: Type of anchored data. + pub fn new(digest: impl Into, seal_type: SealType) -> Self { + Self { + d: Said::new_unchecked(digest.into()), + seal_type, + } + } + + /// Create a seal for a device attestation. + /// + /// Args: + /// * `attestation_digest`: SAID of the attestation JSON. + pub fn device_attestation(attestation_digest: impl Into) -> Self { + Self::new(attestation_digest, SealType::DeviceAttestation) + } + + /// Create a seal for a revocation. + pub fn revocation(revocation_digest: impl Into) -> Self { + Self::new(revocation_digest, SealType::Revocation) + } + + /// Create a seal for capability delegation. + pub fn delegation(delegation_digest: impl Into) -> Self { + Self::new(delegation_digest, SealType::Delegation) + } + + /// Create a seal for an IdP binding. + pub fn idp_binding(binding_digest: impl Into) -> Self { + Self::new(binding_digest, SealType::IdpBinding) + } +} + +// ── Event Types ─────────────────────────────────────────────────────────────── + +/// Inception event — creates a new KERI identity. +/// +/// The inception event establishes the identifier prefix and commits +/// to the first rotation key via the `n` (next) field. +/// +/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct IcpEvent { + /// Version string: "KERI10JSON" + pub v: String, + /// SAID (Self-Addressing Identifier) — Blake3 hash of event + #[serde(default)] + pub d: Said, + /// Identifier prefix (same as `d` for inception) + pub i: Prefix, + /// Sequence number (always 0 for inception) + pub s: KeriSequence, + /// Key threshold: "1" for single-sig + pub kt: String, + /// Current public key(s), Base64url encoded with derivation code + pub k: Vec, + /// Next key threshold: "1" + pub nt: String, + /// Next key commitment(s) — hash of next public key(s) + pub n: Vec, + /// Witness threshold: "0" (no witnesses) + pub bt: String, + /// Witness list (empty) + pub b: Vec, + /// Anchored seals + #[serde(default)] + pub a: Vec, + /// Event signature (Ed25519, base64url-no-pad) + #[serde(default)] + pub x: String, +} + +/// Spec field order: v, t, d, i, s, kt, k, nt, n, bt, b, a, x +impl Serialize for IcpEvent { + fn serialize(&self, serializer: S) -> Result { + let field_count = 10 + + (!self.d.is_empty() as usize) + + (!self.a.is_empty() as usize) + + (!self.x.is_empty() as usize); + let mut map = serializer.serialize_map(Some(field_count))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "icp")?; + if !self.d.is_empty() { + map.serialize_entry("d", &self.d)?; + } + map.serialize_entry("i", &self.i)?; + map.serialize_entry("s", &self.s)?; + map.serialize_entry("kt", &self.kt)?; + map.serialize_entry("k", &self.k)?; + map.serialize_entry("nt", &self.nt)?; + map.serialize_entry("n", &self.n)?; + map.serialize_entry("bt", &self.bt)?; + map.serialize_entry("b", &self.b)?; + if !self.a.is_empty() { + map.serialize_entry("a", &self.a)?; + } + if !self.x.is_empty() { + map.serialize_entry("x", &self.x)?; + } + map.end() + } +} + +/// Rotation event — rotates to pre-committed key. +/// +/// The new key must match the previous event's next-key commitment. +/// This provides cryptographic pre-rotation security. +/// +/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct RotEvent { + /// Version string + pub v: String, + /// SAID of this event + #[serde(default)] + pub d: Said, + /// Identifier prefix + pub i: Prefix, + /// Sequence number (increments with each event) + pub s: KeriSequence, + /// Previous event SAID (creates the hash chain) + pub p: Said, + /// Key threshold + pub kt: String, + /// New current key(s) + pub k: Vec, + /// Next key threshold + pub nt: String, + /// New next key commitment(s) + pub n: Vec, + /// Witness threshold + pub bt: String, + /// Witness list + pub b: Vec, + /// Anchored seals + #[serde(default)] + pub a: Vec, + /// Event signature (Ed25519, base64url-no-pad) + #[serde(default)] + pub x: String, +} + +/// Spec field order: v, t, d, i, s, p, kt, k, nt, n, bt, b, a, x +impl Serialize for RotEvent { + fn serialize(&self, serializer: S) -> Result { + let field_count = 11 + + (!self.d.is_empty() as usize) + + (!self.a.is_empty() as usize) + + (!self.x.is_empty() as usize); + let mut map = serializer.serialize_map(Some(field_count))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "rot")?; + if !self.d.is_empty() { + map.serialize_entry("d", &self.d)?; + } + map.serialize_entry("i", &self.i)?; + map.serialize_entry("s", &self.s)?; + map.serialize_entry("p", &self.p)?; + map.serialize_entry("kt", &self.kt)?; + map.serialize_entry("k", &self.k)?; + map.serialize_entry("nt", &self.nt)?; + map.serialize_entry("n", &self.n)?; + map.serialize_entry("bt", &self.bt)?; + map.serialize_entry("b", &self.b)?; + if !self.a.is_empty() { + map.serialize_entry("a", &self.a)?; + } + if !self.x.is_empty() { + map.serialize_entry("x", &self.x)?; + } + map.end() + } +} + +/// Interaction event — anchors data without key rotation. +/// +/// Used to anchor attestations, delegations, or other data +/// in the KEL without changing keys. +/// +/// Note: The `t` (type) field is handled by the `Event` enum's serde tag. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct IxnEvent { + /// Version string + pub v: String, + /// SAID of this event + #[serde(default)] + pub d: Said, + /// Identifier prefix + pub i: Prefix, + /// Sequence number + pub s: KeriSequence, + /// Previous event SAID + pub p: Said, + /// Anchored seals (the main purpose of IXN events) + pub a: Vec, + /// Event signature (Ed25519, base64url-no-pad) + #[serde(default)] + pub x: String, +} + +/// Spec field order: v, t, d, i, s, p, a, x +impl Serialize for IxnEvent { + fn serialize(&self, serializer: S) -> Result { + let field_count = 6 + (!self.d.is_empty() as usize) + (!self.x.is_empty() as usize); + let mut map = serializer.serialize_map(Some(field_count))?; + map.serialize_entry("v", &self.v)?; + map.serialize_entry("t", "ixn")?; + if !self.d.is_empty() { + map.serialize_entry("d", &self.d)?; + } + map.serialize_entry("i", &self.i)?; + map.serialize_entry("s", &self.s)?; + map.serialize_entry("p", &self.p)?; + map.serialize_entry("a", &self.a)?; + if !self.x.is_empty() { + map.serialize_entry("x", &self.x)?; + } + map.end() + } +} + +/// Unified event enum for processing any KERI event type. +/// +/// Uses serde's tagged enum feature to deserialize based on the `t` field. +/// +/// Usage: +/// ```ignore +/// let event: Event = serde_json::from_str(json)?; +/// match event { +/// Event::Icp(icp) => { /* inception */ } +/// Event::Rot(rot) => { /* rotation */ } +/// Event::Ixn(ixn) => { /* interaction */ } +/// } +/// ``` +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(tag = "t")] +pub enum Event { + /// Inception event + #[serde(rename = "icp")] + Icp(IcpEvent), + /// Rotation event + #[serde(rename = "rot")] + Rot(RotEvent), + /// Interaction event + #[serde(rename = "ixn")] + Ixn(IxnEvent), +} + +impl Serialize for Event { + fn serialize(&self, serializer: S) -> Result { + match self { + Event::Icp(e) => e.serialize(serializer), + Event::Rot(e) => e.serialize(serializer), + Event::Ixn(e) => e.serialize(serializer), + } + } +} + +impl Event { + /// Get the SAID of this event. + pub fn said(&self) -> &Said { + match self { + Event::Icp(e) => &e.d, + Event::Rot(e) => &e.d, + Event::Ixn(e) => &e.d, + } + } + + /// Get the signature of this event. + pub fn signature(&self) -> &str { + match self { + Event::Icp(e) => &e.x, + Event::Rot(e) => &e.x, + Event::Ixn(e) => &e.x, + } + } + + /// Get the sequence number of this event. + pub fn sequence(&self) -> KeriSequence { + match self { + Event::Icp(e) => e.s, + Event::Rot(e) => e.s, + Event::Ixn(e) => e.s, + } + } + + /// Get the identifier prefix. + pub fn prefix(&self) -> &Prefix { + match self { + Event::Icp(e) => &e.i, + Event::Rot(e) => &e.i, + Event::Ixn(e) => &e.i, + } + } + + /// Get the previous event SAID (None for inception). + pub fn previous(&self) -> Option<&Said> { + match self { + Event::Icp(_) => None, + Event::Rot(e) => Some(&e.p), + Event::Ixn(e) => Some(&e.p), + } + } + + /// Get the current keys (only applicable to ICP and ROT events). + pub fn keys(&self) -> Option<&[String]> { + match self { + Event::Icp(e) => Some(&e.k), + Event::Rot(e) => Some(&e.k), + Event::Ixn(_) => None, + } + } + + /// Get the next key commitments (only applicable to ICP and ROT events). + pub fn next_commitments(&self) -> Option<&[String]> { + match self { + Event::Icp(e) => Some(&e.n), + Event::Rot(e) => Some(&e.n), + Event::Ixn(_) => None, + } + } + + /// Get the anchored seals. + pub fn anchors(&self) -> &[Seal] { + match self { + Event::Icp(e) => &e.a, + Event::Rot(e) => &e.a, + Event::Ixn(e) => &e.a, + } + } + + /// Check if this is an inception event. + pub fn is_inception(&self) -> bool { + matches!(self, Event::Icp(_)) + } + + /// Check if this is a rotation event. + pub fn is_rotation(&self) -> bool { + matches!(self, Event::Rot(_)) + } + + /// Check if this is an interaction event. + pub fn is_interaction(&self) -> bool { + matches!(self, Event::Ixn(_)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn keri_sequence_serializes_as_hex() { + assert_eq!( + serde_json::to_string(&KeriSequence::new(0)).unwrap(), + "\"0\"" + ); + assert_eq!( + serde_json::to_string(&KeriSequence::new(10)).unwrap(), + "\"a\"" + ); + assert_eq!( + serde_json::to_string(&KeriSequence::new(255)).unwrap(), + "\"ff\"" + ); + } + + #[test] + fn keri_sequence_deserializes_from_hex() { + let s: KeriSequence = serde_json::from_str("\"0\"").unwrap(); + assert_eq!(s.value(), 0); + let s: KeriSequence = serde_json::from_str("\"a\"").unwrap(); + assert_eq!(s.value(), 10); + let s: KeriSequence = serde_json::from_str("\"ff\"").unwrap(); + assert_eq!(s.value(), 255); + } + + #[test] + fn keri_sequence_rejects_invalid_hex() { + assert!(serde_json::from_str::("\"not_hex\"").is_err()); + } + + #[test] + fn icp_event_omits_empty_d_a_x() { + let icp = IcpEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: Prefix::new_unchecked("ETest123".to_string()), + s: KeriSequence::new(0), + kt: "1".to_string(), + k: vec!["DKey123".to_string()], + nt: "1".to_string(), + n: vec!["ENext456".to_string()], + bt: "0".to_string(), + b: vec![], + a: vec![], + x: String::new(), + }; + let json = serde_json::to_string(&icp).unwrap(); + assert!(!json.contains("\"d\":"), "empty d must be omitted"); + assert!(!json.contains("\"a\":"), "empty a must be omitted"); + assert!(!json.contains("\"x\":"), "empty x must be omitted"); + assert!(json.contains("\"s\":\"0\""), "s must serialize as hex"); + } + + #[test] + fn event_enum_deserializes_by_t_field() { + let json = r#"{"v":"KERI10JSON","t":"icp","i":"E123","s":"0","kt":"1","k":["DKey"],"nt":"1","n":["ENext"],"bt":"0","b":[]}"#; + let event: Event = serde_json::from_str(json).unwrap(); + assert!(event.is_inception()); + assert_eq!(event.sequence().value(), 0); + } + + #[test] + fn seal_serializes_with_kebab_case_type() { + let seal = Seal::device_attestation("EDigest"); + let json = serde_json::to_string(&seal).unwrap(); + assert!(json.contains(r#""type":"device-attestation""#)); + } + + #[test] + fn seal_roundtrips() { + let original = Seal::device_attestation("ETest123"); + let json = serde_json::to_string(&original).unwrap(); + let parsed: Seal = serde_json::from_str(&json).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/crates/auths-keri/src/kel_io.rs b/crates/auths-keri/src/kel_io.rs new file mode 100644 index 00000000..3f0685bf --- /dev/null +++ b/crates/auths-keri/src/kel_io.rs @@ -0,0 +1,127 @@ +//! KEL storage port traits for reading and writing Key Event Logs. +//! +//! These traits are pure KERI abstractions — they operate on serialized event +//! bytes identified by KERI prefixes. They have no dependency on any specific +//! storage backend (git2, SQL, etc.) and compile for WASM targets. + +use crate::Prefix; + +/// Domain error type for KEL storage operations. +/// +/// Variants mirror `auths_core::ports::storage::StorageError` so that +/// a `From for StorageError` impl can bridge between layers. +/// +/// Usage: +/// ```ignore +/// use auths_keri::kel_io::KelStorageError; +/// +/// fn handle(err: KelStorageError) { +/// match err { +/// KelStorageError::NotFound { path } => eprintln!("missing: {path}"), +/// KelStorageError::AlreadyExists { path } => eprintln!("duplicate: {path}"), +/// KelStorageError::CasConflict => eprintln!("concurrent modification"), +/// KelStorageError::Io(msg) => eprintln!("I/O: {msg}"), +/// KelStorageError::Internal(inner) => eprintln!("bug: {inner}"), +/// } +/// } +/// ``` +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum KelStorageError { + /// The item was not found. + #[error("not found: {path}")] + NotFound { + /// Path of the missing item. + path: String, + }, + + /// An item already exists at this path. + #[error("already exists: {path}")] + AlreadyExists { + /// Path of the existing item. + path: String, + }, + + /// Optimistic concurrency conflict; retry the operation. + #[error("compare-and-swap conflict")] + CasConflict, + + /// An I/O error occurred. + #[error("storage I/O error: {0}")] + Io(String), + + /// An unexpected internal error. + #[error("internal storage error: {0}")] + Internal(Box), +} + +/// Reads serialized key event log (KEL) entries for a KERI prefix. +/// +/// Implementations provide access to the ordered event history without +/// exposing how or where events are stored. +/// +/// Usage: +/// ```ignore +/// use auths_keri::kel_io::EventLogReader; +/// use auths_keri::Prefix; +/// +/// fn latest_event(reader: &dyn EventLogReader, prefix: &Prefix) -> Vec { +/// reader.read_event_log(prefix).unwrap() +/// } +/// ``` +pub trait EventLogReader: Send + Sync { + /// Returns the complete serialized event log for the given KERI prefix. + /// + /// Args: + /// * `prefix`: The KERI prefix identifying the event log to read. + /// + /// Usage: + /// ```ignore + /// let prefix = Prefix::new_unchecked("EAbcdef...".into()); + /// let log_bytes = reader.read_event_log(&prefix)?; + /// ``` + fn read_event_log(&self, prefix: &Prefix) -> Result, KelStorageError>; + + /// Returns a single serialized event at the given sequence number. + /// + /// Args: + /// * `prefix`: The KERI prefix identifying the event log. + /// * `seq`: The zero-based sequence number of the event to retrieve. + /// + /// Usage: + /// ```ignore + /// let prefix = Prefix::new_unchecked("EAbcdef...".into()); + /// let inception = reader.read_event_at(&prefix, 0)?; + /// ``` + fn read_event_at(&self, prefix: &Prefix, seq: u64) -> Result, KelStorageError>; +} + +/// Appends serialized key events to a KERI prefix's event log. +/// +/// Implementations handle the mechanics of persisting a new event +/// (e.g., writing a Git commit, inserting a database row) while the +/// domain only provides the serialized event bytes. +/// +/// Usage: +/// ```ignore +/// use auths_keri::kel_io::EventLogWriter; +/// use auths_keri::Prefix; +/// +/// fn record_inception(writer: &dyn EventLogWriter, prefix: &Prefix, event: &[u8]) { +/// writer.append_event(prefix, event).unwrap(); +/// } +/// ``` +pub trait EventLogWriter: Send + Sync { + /// Appends a serialized event to the log for the given KERI prefix. + /// + /// Args: + /// * `prefix`: The KERI prefix identifying the event log. + /// * `event`: The serialized event bytes to append. + /// + /// Usage: + /// ```ignore + /// let prefix = Prefix::new_unchecked("EAbcdef...".into()); + /// writer.append_event(&prefix, &serialized_icp)?; + /// ``` + fn append_event(&self, prefix: &Prefix, event: &[u8]) -> Result<(), KelStorageError>; +} diff --git a/crates/auths-crypto/src/keri.rs b/crates/auths-keri/src/keys.rs similarity index 92% rename from crates/auths-crypto/src/keri.rs rename to crates/auths-keri/src/keys.rs index b97036b8..be2aed09 100644 --- a/crates/auths-crypto/src/keri.rs +++ b/crates/auths-keri/src/keys.rs @@ -9,17 +9,21 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; #[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] #[non_exhaustive] pub enum KeriDecodeError { + /// The KERI derivation code prefix was not 'D' (Ed25519). #[error("Invalid KERI prefix: expected 'D' for Ed25519, got '{0}'")] InvalidPrefix(char), + /// Input string was empty; no derivation code could be read. #[error("Missing KERI prefix: empty string")] EmptyInput, + /// Base64url decoding of the key payload failed. #[error("Base64url decode failed: {0}")] DecodeError(String), + /// Decoded bytes were not exactly 32 (Ed25519 key size). #[error("Invalid Ed25519 key length: expected 32 bytes, got {0}")] InvalidLength(usize), } -impl crate::AuthsErrorInfo for KeriDecodeError { +impl auths_crypto::AuthsErrorInfo for KeriDecodeError { fn error_code(&self) -> &'static str { match self { Self::InvalidPrefix(_) => "AUTHS-E1201", @@ -46,7 +50,7 @@ impl crate::AuthsErrorInfo for KeriDecodeError { /// /// Usage: /// ``` -/// use auths_crypto::KeriPublicKey; +/// use auths_keri::KeriPublicKey; /// /// let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(); /// assert_eq!(key.as_bytes(), &[0u8; 32]); @@ -65,7 +69,7 @@ impl KeriPublicKey { /// /// Usage: /// ``` - /// use auths_crypto::KeriPublicKey; + /// use auths_keri::KeriPublicKey; /// /// let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(); /// let raw = key.as_bytes(); diff --git a/crates/auths-keri/src/lib.rs b/crates/auths-keri/src/lib.rs index 9c89cb43..efac05ee 100644 --- a/crates/auths-keri/src/lib.rs +++ b/crates/auths-keri/src/lib.rs @@ -8,17 +8,24 @@ #![warn(clippy::too_many_lines, clippy::cognitive_complexity)] #![warn(missing_docs)] -//! KERI CESR translation layer for Auths. +//! KERI protocol types, SAID computation, and CESR translation for Auths. //! -//! Provides bidirectional conversion between Auths' internal JSON event -//! representation and spec-compliant CESR streams (Trust over IP KERI v0.9). -//! The reason for this separate crate is to isolate the CESR-specific logic -//! and dependencies, especially given KERI spec is still an evolving draft. +//! The default feature set provides pure KERI types and SAID utilities with +//! no heavy dependencies — suitable for WASM and FFI embedding. //! -//! The core identity crates (`auths-id`, `auths-verifier`) are unchanged. -//! This crate wraps their types for export/import without replacing them. +//! Enable the `cesr` feature for bidirectional conversion between Auths' +//! internal JSON event representation and spec-compliant CESR streams +//! (Trust over IP KERI v0.9). //! -//! Usage: +//! Usage (default, no CESR): +//! ```ignore +//! use auths_keri::{Prefix, Said, compute_said, compute_spec_said}; +//! +//! let said = compute_said(event_bytes); +//! let spec_said = compute_spec_said(&event_json)?; +//! ``` +//! +//! Usage (with CESR feature): //! ```ignore //! use auths_keri::{CesrV1Codec, export_kel_as_cesr}; //! @@ -26,18 +33,49 @@ //! let cesr_stream = export_kel_as_cesr(&codec, &events)?; //! ``` -mod codec; +mod crypto; mod error; +mod events; +pub mod kel_io; +mod keys; +mod said; +mod state; +mod types; +mod validate; +/// Witness protocol types: receipts, providers, and error reporting for split-view defense. +pub mod witness; + +#[cfg(feature = "cesr")] +mod codec; +#[cfg(feature = "cesr")] mod event; +#[cfg(feature = "cesr")] mod roundtrip; -mod said; +#[cfg(feature = "cesr")] mod stream; +#[cfg(feature = "cesr")] mod version; -pub use codec::{CesrCodec, CesrV1Codec, DecodedPrimitive, DigestType, KeyType, SigType}; +pub use crypto::{compute_next_commitment, compute_said, verify_commitment}; pub use error::KeriTranslationError; +pub use events::{Event, IcpEvent, IxnEvent, KERI_VERSION, KeriSequence, RotEvent, Seal, SealType}; +pub use keys::{KeriDecodeError, KeriPublicKey}; +pub use said::{SAID_PLACEHOLDER, compute_spec_said, verify_spec_said}; +pub use state::KeyState; +pub use types::{KeriTypeError, Prefix, Said}; +pub use validate::{ + ValidationError, compute_event_said, finalize_icp_event, find_seal_in_kel, parse_kel_json, + replay_kel, serialize_for_signing, validate_for_append, validate_kel, verify_event_crypto, + verify_event_said, +}; + +#[cfg(feature = "cesr")] +pub use codec::{CesrCodec, CesrV1Codec, DecodedPrimitive, DigestType, KeyType, SigType}; +#[cfg(feature = "cesr")] pub use event::{SerializedEvent, decode_cesr_key, serialize_for_cesr}; +#[cfg(feature = "cesr")] pub use roundtrip::{export_kel_as_cesr, import_cesr_to_events}; -pub use said::{compute_spec_said, verify_spec_said}; +#[cfg(feature = "cesr")] pub use stream::{AttachmentGroup, CesrStream, assemble_cesr_stream}; +#[cfg(feature = "cesr")] pub use version::compute_version_string; diff --git a/crates/auths-keri/src/roundtrip.rs b/crates/auths-keri/src/roundtrip.rs index b0d6249c..2810227e 100644 --- a/crates/auths-keri/src/roundtrip.rs +++ b/crates/auths-keri/src/roundtrip.rs @@ -1,4 +1,3 @@ -use auths_verifier::keri::KeriEvent; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use cesride::{Indexer, Siger}; @@ -8,11 +7,11 @@ use crate::error::KeriTranslationError; use crate::event::serialize_for_cesr; use crate::stream::{CesrStream, assemble_cesr_stream}; -/// Exports a sequence of Auths internal events as a CESR stream. +/// Exports a sequence of KERI events as a CESR stream. /// /// Args: /// * `codec`: The CESR codec. -/// * `events`: Internal Auths events in sequence order. +/// * `events`: KERI events as JSON values in sequence order. /// /// Usage: /// ```ignore @@ -24,7 +23,7 @@ use crate::stream::{CesrStream, assemble_cesr_stream}; /// ``` pub fn export_kel_as_cesr( codec: &dyn CesrCodec, - events: &[KeriEvent], + events: &[serde_json::Value], ) -> Result { let serialized: Vec<_> = events .iter() @@ -34,10 +33,10 @@ pub fn export_kel_as_cesr( assemble_cesr_stream(codec, &serialized) } -/// Imports a CESR stream and converts it back to Auths internal events. +/// Imports a CESR stream and converts it back to KERI event JSON values. /// /// Parses the CESR stream, extracts JSON event bodies and attached signatures, -/// and reconstitutes `KeriEvent` types with signatures re-embedded in the `x` field +/// and reconstitutes event objects with signatures re-embedded in the `x` field /// as base64url-no-pad. /// /// Args: @@ -46,7 +45,7 @@ pub fn export_kel_as_cesr( pub fn import_cesr_to_events( _codec: &dyn CesrCodec, cesr_bytes: &[u8], -) -> Result, KeriTranslationError> { +) -> Result, KeriTranslationError> { let mut offset = 0; let mut events = Vec::new(); @@ -77,9 +76,7 @@ pub fn import_cesr_to_events( obj.insert("x".to_string(), serde_json::Value::String(b64)); } - let event: KeriEvent = - serde_json::from_value(value).map_err(KeriTranslationError::SerializationFailed)?; - events.push(event); + events.push(value); offset = find_next_event_start(cesr_bytes, attachment_start); } diff --git a/crates/auths-keri/src/said.rs b/crates/auths-keri/src/said.rs index bb9144f1..aa3e6917 100644 --- a/crates/auths-keri/src/said.rs +++ b/crates/auths-keri/src/said.rs @@ -1,4 +1,5 @@ -use crate::codec::{CesrCodec, DigestType}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use crate::error::KeriTranslationError; /// The 44-character `#` placeholder injected into the `d` field (and `i` field @@ -14,15 +15,11 @@ pub const SAID_PLACEHOLDER: &str = "############################################ /// 3. Remove the `x` field entirely (signatures are detached). /// 4. Serialize with `serde_json::to_vec` (insertion-order, NOT json-canon). /// 5. Blake3-256 hash the bytes. -/// 6. CESR-encode the digest with the `E` derivation code. +/// 6. CESR-encode the digest: `E` derivation code + base64url-no-pad (Blake3-256). /// /// Args: -/// * `codec`: The CESR codec for digest encoding. /// * `event`: The event as a JSON object. -pub fn compute_spec_said( - codec: &dyn CesrCodec, - event: &serde_json::Value, -) -> Result { +pub fn compute_spec_said(event: &serde_json::Value) -> Result { let mut obj = event .as_object() .ok_or(KeriTranslationError::MissingField { @@ -45,31 +42,25 @@ pub fn compute_spec_said( obj.remove("x"); - let placeholder_value = serde_json::Value::Object(obj); - let serialized = serde_json::to_vec(&placeholder_value) + let serialized = serde_json::to_vec(&serde_json::Value::Object(obj)) .map_err(KeriTranslationError::SerializationFailed)?; let hash = blake3::hash(&serialized); - - codec.encode_digest(hash.as_bytes(), DigestType::Blake3_256) + Ok(format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes()))) } /// Verifies that an event's `d` field matches the spec-compliant SAID. /// /// Args: -/// * `codec`: The CESR codec. /// * `event`: The event JSON with a populated `d` field. -pub fn verify_spec_said( - codec: &dyn CesrCodec, - event: &serde_json::Value, -) -> Result<(), KeriTranslationError> { +pub fn verify_spec_said(event: &serde_json::Value) -> Result<(), KeriTranslationError> { let found = event .get("d") .and_then(|v| v.as_str()) .ok_or(KeriTranslationError::MissingField { field: "d" })? .to_string(); - let computed = compute_spec_said(codec, event)?; + let computed = compute_spec_said(event)?; if computed != found { return Err(KeriTranslationError::SaidMismatch { computed, found }); @@ -81,11 +72,9 @@ pub fn verify_spec_said( #[cfg(test)] mod tests { use super::*; - use crate::codec::CesrV1Codec; #[test] fn said_has_correct_length() { - let codec = CesrV1Codec::new(); let event = serde_json::json!({ "v": "KERI10JSON000000_", "t": "icp", @@ -100,14 +89,13 @@ mod tests { "b": [], "a": [] }); - let said = compute_spec_said(&codec, &event).unwrap(); + let said = compute_spec_said(&event).unwrap(); assert_eq!(said.len(), 44); assert!(said.starts_with('E')); } #[test] fn said_is_deterministic() { - let codec = CesrV1Codec::new(); let event = serde_json::json!({ "v": "KERI10JSON000000_", "t": "rot", @@ -123,14 +111,13 @@ mod tests { "b": [], "a": [] }); - let said1 = compute_spec_said(&codec, &event).unwrap(); - let said2 = compute_spec_said(&codec, &event).unwrap(); + let said1 = compute_spec_said(&event).unwrap(); + let said2 = compute_spec_said(&event).unwrap(); assert_eq!(said1, said2); } #[test] fn said_ignores_x_field() { - let codec = CesrV1Codec::new(); let event_with_x = serde_json::json!({ "v": "KERI10JSON000000_", "t": "icp", @@ -160,17 +147,13 @@ mod tests { "b": [], "a": [] }); - let said_with = compute_spec_said(&codec, &event_with_x).unwrap(); - let said_without = compute_spec_said(&codec, &event_without_x).unwrap(); + let said_with = compute_spec_said(&event_with_x).unwrap(); + let said_without = compute_spec_said(&event_without_x).unwrap(); assert_eq!(said_with, said_without, "x field must not affect SAID"); } #[test] fn inception_applies_i_placeholder() { - let codec = CesrV1Codec::new(); - // For inception, compute_spec_said replaces both d and i with placeholder - // This means two events with different i values but same t=icp should - // produce the same SAID (since i gets overwritten) let event_a = serde_json::json!({ "v": "KERI10JSON000000_", "t": "icp", @@ -199,8 +182,8 @@ mod tests { "b": [], "a": [] }); - let said_a = compute_spec_said(&codec, &event_a).unwrap(); - let said_b = compute_spec_said(&codec, &event_b).unwrap(); + let said_a = compute_spec_said(&event_a).unwrap(); + let said_b = compute_spec_said(&event_b).unwrap(); assert_eq!( said_a, said_b, "inception SAID must be independent of initial i value" @@ -209,7 +192,6 @@ mod tests { #[test] fn verify_spec_said_accepts_correct() { - let codec = CesrV1Codec::new(); let event = serde_json::json!({ "v": "KERI10JSON000000_", "t": "rot", @@ -225,15 +207,14 @@ mod tests { "b": [], "a": [] }); - let said = compute_spec_said(&codec, &event).unwrap(); + let said = compute_spec_said(&event).unwrap(); let mut event_with_said = event.clone(); event_with_said["d"] = serde_json::Value::String(said); - assert!(verify_spec_said(&codec, &event_with_said).is_ok()); + assert!(verify_spec_said(&event_with_said).is_ok()); } #[test] fn verify_spec_said_rejects_wrong() { - let codec = CesrV1Codec::new(); let event = serde_json::json!({ "v": "KERI10JSON000000_", "t": "rot", @@ -249,6 +230,6 @@ mod tests { "b": [], "a": [] }); - assert!(verify_spec_said(&codec, &event).is_err()); + assert!(verify_spec_said(&event).is_err()); } } diff --git a/crates/auths-keri/src/state.rs b/crates/auths-keri/src/state.rs new file mode 100644 index 00000000..82b2e884 --- /dev/null +++ b/crates/auths-keri/src/state.rs @@ -0,0 +1,222 @@ +//! Key state derived from replaying a KERI event log. +//! +//! The `KeyState` represents the current cryptographic state of a KERI +//! identity after processing all events in its KEL. This is the "resolved" +//! state used for signature verification and capability checking. + +use serde::{Deserialize, Serialize}; + +use crate::types::{Prefix, Said}; + +/// Current key state derived from replaying a KEL. +/// +/// This struct captures the complete state of a KERI identity at a given +/// point in its event log. It is computed by walking the KEL from inception +/// to the latest event. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct KeyState { + /// The KERI identifier prefix (used in `did:keri:`) + pub prefix: Prefix, + + /// Current signing key(s), Base64url encoded with derivation code prefix. + /// For Ed25519 keys, this is "D" + base64url(pubkey). + pub current_keys: Vec, + + /// Next key commitment(s) for pre-rotation. + /// These are Blake3 hashes of the next public key(s). + pub next_commitment: Vec, + + /// Current sequence number (0 for inception, increments with each event) + pub sequence: u64, + + /// SAID of the last processed event + pub last_event_said: Said, + + /// Whether this identity has been abandoned (empty next commitment) + pub is_abandoned: bool, + /// Current signing threshold + pub threshold: u64, + /// Next signing threshold (committed) + pub next_threshold: u64, +} + +impl KeyState { + /// Create initial state from an inception event. + /// + /// Args: + /// * `prefix` - The KERI identifier (same as inception SAID) + /// * `keys` - The initial signing key(s) + /// * `next` - The next-key commitment(s) + /// * `threshold` - Initial signing threshold + /// * `next_threshold` - Committed next signing threshold + /// * `said` - The inception event SAID + pub fn from_inception( + prefix: Prefix, + keys: Vec, + next: Vec, + threshold: u64, + next_threshold: u64, + said: Said, + ) -> Self { + Self { + prefix, + current_keys: keys, + next_commitment: next.clone(), + sequence: 0, + last_event_said: said, + is_abandoned: next.is_empty(), + threshold, + next_threshold, + } + } + + /// Apply a rotation event to update state. + /// + /// This should only be called after verifying: + /// 1. The new key matches the previous next_commitment + /// 2. The event's previous SAID matches last_event_said + /// 3. The sequence is exactly last_sequence + 1 + pub fn apply_rotation( + &mut self, + new_keys: Vec, + new_next: Vec, + threshold: u64, + next_threshold: u64, + sequence: u64, + said: Said, + ) { + self.current_keys = new_keys; + self.next_commitment = new_next.clone(); + self.threshold = threshold; + self.next_threshold = next_threshold; + self.sequence = sequence; + self.last_event_said = said; + self.is_abandoned = new_next.is_empty(); + } + + /// Apply an interaction event (updates sequence and SAID only). + /// + /// Interaction events anchor data but don't change keys. + pub fn apply_interaction(&mut self, sequence: u64, said: Said) { + self.sequence = sequence; + self.last_event_said = said; + } + + /// Get the current signing key (first key for single-sig). + /// + /// Returns the encoded key string (e.g., "DBase64EncodedKey...") + pub fn current_key(&self) -> Option<&str> { + self.current_keys.first().map(|s| s.as_str()) + } + + /// Check if key can be rotated. + /// + /// Returns `false` if the identity has been abandoned (empty next commitment). + pub fn can_rotate(&self) -> bool { + !self.is_abandoned && !self.next_commitment.is_empty() + } + + /// Get the DID for this identity. + pub fn did(&self) -> String { + format!("did:keri:{}", self.prefix.as_str()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn key_state_from_inception() { + let state = KeyState::from_inception( + Prefix::new_unchecked("EPrefix".to_string()), + vec!["DKey1".to_string()], + vec!["ENext1".to_string()], + 1, + 1, + Said::new_unchecked("ESAID".to_string()), + ); + assert_eq!(state.sequence, 0); + assert!(!state.is_abandoned); + assert!(state.can_rotate()); + assert_eq!(state.current_key(), Some("DKey1")); + assert_eq!(state.did(), "did:keri:EPrefix"); + } + + #[test] + fn key_state_apply_rotation() { + let mut state = KeyState::from_inception( + Prefix::new_unchecked("EPrefix".to_string()), + vec!["DKey1".to_string()], + vec!["ENext1".to_string()], + 1, + 1, + Said::new_unchecked("ESAID1".to_string()), + ); + + state.apply_rotation( + vec!["DKey2".to_string()], + vec!["ENext2".to_string()], + 1, + 1, + 1, + Said::new_unchecked("ESAID2".to_string()), + ); + + assert_eq!(state.sequence, 1); + assert_eq!(state.current_keys[0], "DKey2"); + assert_eq!(state.next_commitment[0], "ENext2"); + assert_eq!(state.last_event_said, "ESAID2"); + assert!(state.can_rotate()); + } + + #[test] + fn key_state_apply_interaction() { + let mut state = KeyState::from_inception( + Prefix::new_unchecked("EPrefix".to_string()), + vec!["DKey1".to_string()], + vec!["ENext1".to_string()], + 1, + 1, + Said::new_unchecked("ESAID1".to_string()), + ); + + state.apply_interaction(1, Said::new_unchecked("ESAID_IXN".to_string())); + + assert_eq!(state.sequence, 1); + assert_eq!(state.current_keys[0], "DKey1"); + assert_eq!(state.last_event_said, "ESAID_IXN"); + } + + #[test] + fn abandoned_identity_cannot_rotate() { + let state = KeyState::from_inception( + Prefix::new_unchecked("EPrefix".to_string()), + vec!["DKey1".to_string()], + vec![], + 1, + 0, + Said::new_unchecked("ESAID".to_string()), + ); + assert!(state.is_abandoned); + assert!(!state.can_rotate()); + } + + #[test] + fn key_state_serializes() { + let state = KeyState::from_inception( + Prefix::new_unchecked("EPrefix".to_string()), + vec!["DKey1".to_string()], + vec!["ENext1".to_string()], + 1, + 1, + Said::new_unchecked("ESAID".to_string()), + ); + + let json = serde_json::to_string(&state).unwrap(); + let parsed: KeyState = serde_json::from_str(&json).unwrap(); + assert_eq!(state, parsed); + } +} diff --git a/crates/auths-keri/src/types.rs b/crates/auths-keri/src/types.rs new file mode 100644 index 00000000..9c51b589 --- /dev/null +++ b/crates/auths-keri/src/types.rs @@ -0,0 +1,229 @@ +use std::borrow::Borrow; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// ── KERI Identifier Newtypes ──────────────────────────────────────────────── + +/// Error when constructing KERI newtypes with invalid values. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +#[error("Invalid KERI {type_name}: {reason}")] +pub struct KeriTypeError { + /// Which KERI type failed validation. + pub type_name: &'static str, + /// Why validation failed. + pub reason: String, +} + +/// Shared validation for KERI self-addressing identifiers. +/// +/// Both `Prefix` and `Said` must start with 'E' (Blake3-256 derivation code). +fn validate_keri_derivation_code(s: &str, type_label: &'static str) -> Result<(), KeriTypeError> { + if s.is_empty() { + return Err(KeriTypeError { + type_name: type_label, + reason: "must not be empty".into(), + }); + } + if !s.starts_with('E') { + return Err(KeriTypeError { + type_name: type_label, + reason: format!( + "must start with 'E' (Blake3 derivation code), got '{}'", + &s[..s.len().min(10)] + ), + }); + } + Ok(()) +} + +/// Strongly-typed KERI identifier prefix (e.g., `"ETest123..."`). +/// +/// A prefix is the self-addressing identifier derived from the inception event's +/// Blake3 hash. Always starts with 'E' (Blake3-256 derivation code). +/// +/// Args: +/// * Inner `String` should start with `'E'` (enforced by `new()`, not by serde). +/// +/// Usage: +/// ```ignore +/// let prefix = Prefix::new("ETest123abc".to_string())?; +/// assert_eq!(prefix.as_str(), "ETest123abc"); +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[repr(transparent)] +pub struct Prefix(String); + +impl Prefix { + /// Validates and wraps a KERI prefix string. + pub fn new(s: String) -> Result { + validate_keri_derivation_code(&s, "Prefix")?; + Ok(Self(s)) + } + + /// Wraps a prefix string without validation (for trusted internal paths). + pub fn new_unchecked(s: String) -> Self { + Self(s) + } + + /// Returns the inner string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes self and returns the inner String. + pub fn into_inner(self) -> String { + self.0 + } + + /// Returns true if the inner string is empty (placeholder during event construction). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Display for Prefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for Prefix { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Borrow for Prefix { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(p: Prefix) -> String { + p.0 + } +} + +impl PartialEq for Prefix { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Prefix { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for str { + fn eq(&self, other: &Prefix) -> bool { + self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Prefix) -> bool { + *self == other.0 + } +} + +/// KERI Self-Addressing Identifier (SAID). +/// +/// A Blake3 hash that uniquely identifies a KERI event. Creates the +/// hash chain: each event's `p` (previous) field is the prior event's SAID. +/// +/// Structurally identical to `Prefix` (both start with 'E') but semantically +/// distinct — a prefix identifies an *identity*, a SAID identifies an *event*. +/// +/// Args: +/// * Inner `String` should start with `'E'` (enforced by `new()`, not by serde). +/// +/// Usage: +/// ```ignore +/// let said = Said::new("ESAID123".to_string())?; +/// assert_eq!(said.as_str(), "ESAID123"); +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[repr(transparent)] +pub struct Said(String); + +impl Said { + /// Validates and wraps a KERI SAID string. + pub fn new(s: String) -> Result { + validate_keri_derivation_code(&s, "Said")?; + Ok(Self(s)) + } + + /// Wraps a SAID string without validation (for `compute_said()` output and storage loads). + pub fn new_unchecked(s: String) -> Self { + Self(s) + } + + /// Returns the inner string slice. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes self and returns the inner String. + pub fn into_inner(self) -> String { + self.0 + } + + /// Returns true if the inner string is empty (placeholder during event construction). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Display for Said { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for Said { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Borrow for Said { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(s: Said) -> String { + s.0 + } +} + +impl PartialEq for Said { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Said { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for str { + fn eq(&self, other: &Said) -> bool { + self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Said) -> bool { + *self == other.0 + } +} diff --git a/crates/auths-keri/src/validate.rs b/crates/auths-keri/src/validate.rs new file mode 100644 index 00000000..3198ee98 --- /dev/null +++ b/crates/auths-keri/src/validate.rs @@ -0,0 +1,777 @@ +//! KEL validation: SAID verification, chain linkage, signature verification, +//! and pre-rotation commitment checks. +//! +//! This module provides validation functions for ensuring a Key Event Log +//! is cryptographically valid and properly chained. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + +use crate::keys::KeriPublicKey; +use ring::signature::UnparsedPublicKey; + +use crate::crypto::{compute_said, verify_commitment}; +use crate::events::{Event, IcpEvent, IxnEvent, RotEvent}; +use crate::state::KeyState; +use crate::types::{Prefix, Said}; + +/// Errors specific to KEL validation. +/// +/// These errors represent **protocol invariant violations**. They indicate +/// structural corruption or attack, not recoverable conditions. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum ValidationError { + /// SAID (Self-Addressing Identifier) doesn't match content hash. + #[error("Invalid SAID: expected {expected}, got {actual}")] + InvalidSaid { + /// The SAID that was expected from the content hash. + expected: Said, + /// The SAID that was actually found in the event. + actual: Said, + }, + + /// Event references wrong previous event. + #[error("Broken chain: event {sequence} references {referenced}, but previous was {actual}")] + BrokenChain { + /// Zero-based position of the event in the KEL. + sequence: u64, + /// The previous SAID referenced by this event. + referenced: Said, + /// The actual SAID of the previous event. + actual: Said, + }, + + /// Sequence number is not monotonically increasing. + #[error("Invalid sequence: expected {expected}, got {actual}")] + InvalidSequence { + /// The sequence number that was expected. + expected: u64, + /// The sequence number that was found. + actual: u64, + }, + + /// Pre-rotation commitment doesn't match the new current key. + #[error("Pre-rotation commitment mismatch at sequence {sequence}")] + CommitmentMismatch { + /// Zero-based position of the rotation event that failed. + sequence: u64, + }, + + /// Cryptographic signature verification failed for an event. + #[error("Signature verification failed at sequence {sequence}")] + SignatureFailed { + /// Zero-based position of the event whose signature failed. + sequence: u64, + }, + + /// The first event in a KEL must be an Inception event. + #[error("First event must be inception")] + NotInception, + + /// The KEL contains no events. + #[error("Empty KEL")] + EmptyKel, + + /// More than one Inception event was found in the KEL. + #[error("Multiple inception events in KEL")] + MultipleInceptions, + + /// JSON serialization or deserialization failed. + #[error("Serialization error: {0}")] + Serialization(String), + + /// A sequence field could not be parsed as a valid hex number. + #[error("Malformed sequence number: {raw:?}")] + MalformedSequence { + /// The raw string that could not be parsed. + raw: String, + }, + + /// The key encoding prefix is unsupported or malformed. + #[error("Invalid key encoding: {0}")] + InvalidKey(String), +} + +/// Validate a KEL and return the resulting KeyState. +/// +/// This is a **pure function** serving as the core entrypoint for KEL replay. +/// +/// Args: +/// * `events` - The ordered list of KERI events to validate. +/// +/// Usage: +/// ```ignore +/// let key_state = validate_kel(&events)?; +/// ``` +pub fn validate_kel(events: &[Event]) -> Result { + if events.is_empty() { + return Err(ValidationError::EmptyKel); + } + + let Event::Icp(icp) = &events[0] else { + return Err(ValidationError::NotInception); + }; + + verify_event_said(&events[0])?; + let mut state = validate_inception(icp)?; + + for (idx, event) in events.iter().enumerate().skip(1) { + let expected_seq = idx as u64; + verify_event_said(event)?; + verify_sequence(event, expected_seq)?; + verify_chain_linkage(event, &state)?; + + match event { + Event::Rot(rot) => validate_rotation(rot, event, expected_seq, &mut state)?, + Event::Ixn(ixn) => validate_interaction(ixn, event, expected_seq, &mut state)?, + Event::Icp(_) => return Err(ValidationError::MultipleInceptions), + } + } + + Ok(state) +} + +fn parse_threshold(raw: &str) -> Result { + raw.parse::() + .map_err(|_| ValidationError::MalformedSequence { + raw: raw.to_string(), + }) +} + +fn validate_inception(icp: &IcpEvent) -> Result { + verify_event_signature( + &Event::Icp(icp.clone()), + icp.k + .first() + .ok_or(ValidationError::SignatureFailed { sequence: 0 })?, + )?; + + let threshold = parse_threshold(&icp.kt)?; + let next_threshold = parse_threshold(&icp.nt)?; + + Ok(KeyState::from_inception( + icp.i.clone(), + icp.k.clone(), + icp.n.clone(), + threshold, + next_threshold, + icp.d.clone(), + )) +} + +fn verify_sequence(event: &Event, expected: u64) -> Result<(), ValidationError> { + let actual = event.sequence().value(); + if actual != expected { + return Err(ValidationError::InvalidSequence { expected, actual }); + } + Ok(()) +} + +fn verify_chain_linkage(event: &Event, state: &KeyState) -> Result<(), ValidationError> { + let prev_said = event.previous().ok_or(ValidationError::NotInception)?; + if *prev_said != state.last_event_said { + return Err(ValidationError::BrokenChain { + sequence: event.sequence().value(), + referenced: prev_said.clone(), + actual: state.last_event_said.clone(), + }); + } + Ok(()) +} + +fn validate_rotation( + rot: &RotEvent, + event: &Event, + sequence: u64, + state: &mut KeyState, +) -> Result<(), ValidationError> { + if !rot.k.is_empty() { + verify_event_signature(event, &rot.k[0])?; + } + + if !state.next_commitment.is_empty() && !rot.k.is_empty() { + let key_bytes = KeriPublicKey::parse(&rot.k[0]) + .map(|k| k.as_bytes().to_vec()) + .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; + + if !verify_commitment(&key_bytes, &state.next_commitment[0]) { + return Err(ValidationError::CommitmentMismatch { sequence }); + } + } + + let threshold = parse_threshold(&rot.kt)?; + let next_threshold = parse_threshold(&rot.nt)?; + + state.apply_rotation( + rot.k.clone(), + rot.n.clone(), + threshold, + next_threshold, + sequence, + rot.d.clone(), + ); + + Ok(()) +} + +fn validate_interaction( + ixn: &IxnEvent, + event: &Event, + sequence: u64, + state: &mut KeyState, +) -> Result<(), ValidationError> { + let current_key = state + .current_key() + .ok_or(ValidationError::SignatureFailed { sequence })?; + verify_event_signature(event, current_key)?; + state.apply_interaction(sequence, ixn.d.clone()); + Ok(()) +} + +/// Replay a KEL to get the current KeyState. +/// +/// Alias for [`validate_kel`] — use whichever name fits your context better. +/// +/// Args: +/// * `events` - The ordered list of KERI events to replay. +pub fn replay_kel(events: &[Event]) -> Result { + validate_kel(events) +} + +/// Validate the cryptographic integrity of a single event against the current key state. +/// +/// Args: +/// * `event` - The event to validate. +/// * `current_state` - The current `KeyState` (None for inception events). +pub fn verify_event_crypto( + event: &Event, + current_state: Option<&KeyState>, +) -> Result<(), ValidationError> { + match event { + Event::Icp(icp) => { + let key = icp + .k + .first() + .ok_or(ValidationError::SignatureFailed { sequence: 0 })?; + verify_event_signature(event, key)?; + + if icp.i.as_str() != icp.d.as_str() { + return Err(ValidationError::InvalidSaid { + expected: icp.d.clone(), + actual: Said::new_unchecked(icp.i.as_str().to_string()), + }); + } + + Ok(()) + } + Event::Rot(rot) => { + let sequence = event.sequence().value(); + let state = current_state.ok_or(ValidationError::SignatureFailed { sequence })?; + + if state.is_abandoned || state.next_commitment.is_empty() { + return Err(ValidationError::CommitmentMismatch { sequence }); + } + + if rot.k.is_empty() { + return Err(ValidationError::SignatureFailed { sequence }); + } + verify_event_signature(event, &rot.k[0])?; + + let key_str = &rot.k[0]; + let key_bytes = KeriPublicKey::parse(key_str) + .map(|k| k.as_bytes().to_vec()) + .map_err(|_| ValidationError::CommitmentMismatch { sequence })?; + + if !verify_commitment(&key_bytes, &state.next_commitment[0]) { + return Err(ValidationError::CommitmentMismatch { sequence }); + } + + Ok(()) + } + Event::Ixn(_) => { + let sequence = event.sequence().value(); + let state = current_state.ok_or(ValidationError::SignatureFailed { sequence })?; + + let current_key = state + .current_key() + .ok_or(ValidationError::SignatureFailed { sequence })?; + verify_event_signature(event, current_key)?; + + Ok(()) + } + } +} + +/// Verify an event's SAID matches its content hash. +/// +/// Args: +/// * `event` - The event to verify. +pub fn verify_event_said(event: &Event) -> Result<(), ValidationError> { + let json = serialize_for_said(event)?; + let computed = compute_said(&json); + let actual = event.said(); + + if computed != actual.as_str() { + return Err(ValidationError::InvalidSaid { + expected: computed, + actual: actual.clone(), + }); + } + + Ok(()) +} + +/// Validate a single event for appending to a KEL with known state. +/// +/// Args: +/// * `event` - The event to validate for append. +/// * `state` - The current `KeyState` (tip of the existing KEL). +pub fn validate_for_append(event: &Event, state: &KeyState) -> Result<(), ValidationError> { + if matches!(event, Event::Icp(_)) { + return Err(ValidationError::MultipleInceptions); + } + + verify_event_said(event)?; + verify_sequence(event, state.sequence + 1)?; + verify_chain_linkage(event, state)?; + verify_event_crypto(event, Some(state))?; + + Ok(()) +} + +/// Compute the SAID for an event. +/// +/// Args: +/// * `event` - The event to compute the SAID for. +pub fn compute_event_said(event: &Event) -> Result { + let json = serialize_for_said(event)?; + Ok(compute_said(&json)) +} + +/// Serialize an event for SAID computation (with empty `d`, `i` for icp, and `x` fields). +fn serialize_for_said(event: &Event) -> Result, ValidationError> { + match event { + Event::Icp(e) => { + let mut e = e.clone(); + e.d = Said::default(); + e.i = Prefix::default(); + e.x = String::new(); + serde_json::to_vec(&Event::Icp(e)) + } + Event::Rot(e) => { + let mut e = e.clone(); + e.d = Said::default(); + e.x = String::new(); + serde_json::to_vec(&Event::Rot(e)) + } + Event::Ixn(e) => { + let mut e = e.clone(); + e.d = Said::default(); + e.x = String::new(); + serde_json::to_vec(&Event::Ixn(e)) + } + } + .map_err(|e| ValidationError::Serialization(e.to_string())) +} + +/// Serialize event for signing (clears d, i for icp, and x fields). +/// +/// Args: +/// * `event` - The event to serialize for signing. +pub fn serialize_for_signing(event: &Event) -> Result, ValidationError> { + match event { + Event::Icp(e) => { + let mut e = e.clone(); + e.d = Said::default(); + e.i = Prefix::default(); + e.x = String::new(); + serde_json::to_vec(&Event::Icp(e)) + } + Event::Rot(e) => { + let mut e = e.clone(); + e.d = Said::default(); + e.x = String::new(); + serde_json::to_vec(&Event::Rot(e)) + } + Event::Ixn(e) => { + let mut e = e.clone(); + e.d = Said::default(); + e.x = String::new(); + serde_json::to_vec(&Event::Ixn(e)) + } + } + .map_err(|e| ValidationError::Serialization(e.to_string())) +} + +/// Verify an event's signature using the specified key. +fn verify_event_signature(event: &Event, signing_key: &str) -> Result<(), ValidationError> { + let sequence = event.sequence().value(); + + let sig_str = event.signature(); + if sig_str.is_empty() { + return Err(ValidationError::SignatureFailed { sequence }); + } + let sig_bytes = URL_SAFE_NO_PAD + .decode(sig_str) + .map_err(|_| ValidationError::SignatureFailed { sequence })?; + + let key_bytes = KeriPublicKey::parse(signing_key) + .map_err(|_| ValidationError::SignatureFailed { sequence })?; + + let canonical = serialize_for_signing(event)?; + + let pk = UnparsedPublicKey::new(&ring::signature::ED25519, key_bytes.as_bytes()); + pk.verify(&canonical, &sig_bytes) + .map_err(|_| ValidationError::SignatureFailed { sequence })?; + + Ok(()) +} + +/// Create an inception event with a properly computed SAID. +/// +/// Args: +/// * `icp` - The inception event to finalize. +pub fn finalize_icp_event(mut icp: IcpEvent) -> Result { + icp.d = Said::default(); + icp.i = Prefix::default(); + + let json = serde_json::to_vec(&Event::Icp(icp.clone())) + .map_err(|e| ValidationError::Serialization(e.to_string()))?; + let said = compute_said(&json); + + icp.d = said.clone(); + icp.i = Prefix::new_unchecked(said.into_inner()); + + Ok(icp) +} + +/// Search for a seal with the given digest in any IXN event in the KEL. +/// +/// Returns the sequence number of the IXN event if found. +/// +/// Args: +/// * `events` - The event log to search. +/// * `digest` - The SAID digest to search for. +pub fn find_seal_in_kel(events: &[Event], digest: &str) -> Option { + for event in events { + if let Event::Ixn(ixn) = event { + for seal in &ixn.a { + if seal.d.as_str() == digest { + return Some(ixn.s.value()); + } + } + } + } + None +} + +/// Parse a KEL from a JSON string. +/// +/// Args: +/// * `json` - JSON string containing a list of KERI events. +pub fn parse_kel_json(json: &str) -> Result, ValidationError> { + serde_json::from_str(json).map_err(|e| ValidationError::Serialization(e.to_string())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::events::{KERI_VERSION, KeriSequence, Seal}; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use ring::rand::SystemRandom; + use ring::signature::{Ed25519KeyPair, KeyPair}; + + fn make_raw_icp(key: &str, next: &str) -> IcpEvent { + IcpEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: "1".to_string(), + k: vec![key.to_string()], + nt: "1".to_string(), + n: vec![next.to_string()], + bt: "0".to_string(), + b: vec![], + a: vec![], + x: String::new(), + } + } + + fn make_signed_icp() -> (IcpEvent, Ed25519KeyPair) { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); + + let icp = IcpEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: "1".to_string(), + k: vec![key_encoded], + nt: "1".to_string(), + n: vec!["ENextCommitment".to_string()], + bt: "0".to_string(), + b: vec![], + a: vec![], + x: String::new(), + }; + + let mut finalized = finalize_icp_event(icp).unwrap(); + let canonical = serialize_for_signing(&Event::Icp(finalized.clone())).unwrap(); + let sig = keypair.sign(&canonical); + finalized.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + (finalized, keypair) + } + + fn make_signed_ixn( + prefix: &Prefix, + prev_said: &Said, + seq: u64, + keypair: &Ed25519KeyPair, + ) -> IxnEvent { + let mut ixn = IxnEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: prefix.clone(), + s: KeriSequence::new(seq), + p: prev_said.clone(), + a: vec![Seal::device_attestation("EAttest")], + x: String::new(), + }; + + let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&json); + + let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); + let sig = keypair.sign(&canonical); + ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + ixn + } + + #[test] + fn finalize_icp_sets_said() { + let icp = make_raw_icp("DKey1", "ENext1"); + let finalized = finalize_icp_event(icp).unwrap(); + + assert!(!finalized.d.is_empty()); + assert_eq!(finalized.d.as_str(), finalized.i.as_str()); + assert!(finalized.d.as_str().starts_with('E')); + } + + #[test] + fn validates_single_inception() { + let (icp, _keypair) = make_signed_icp(); + let events = vec![Event::Icp(icp.clone())]; + + let state = validate_kel(&events).unwrap(); + assert_eq!(state.prefix, icp.i); + assert_eq!(state.sequence, 0); + } + + #[test] + fn rejects_empty_kel() { + let result = validate_kel(&[]); + assert!(matches!(result, Err(ValidationError::EmptyKel))); + } + + #[test] + fn rejects_non_inception_first() { + let ixn = IxnEvent { + v: KERI_VERSION.to_string(), + d: Said::new_unchecked("ETest".to_string()), + i: Prefix::new_unchecked("ETest".to_string()), + s: KeriSequence::new(0), + p: Said::new_unchecked("EPrev".to_string()), + a: vec![], + x: String::new(), + }; + let events = vec![Event::Ixn(ixn)]; + let result = validate_kel(&events); + assert!(matches!(result, Err(ValidationError::NotInception))); + } + + #[test] + fn rejects_broken_sequence() { + let (icp, keypair) = make_signed_icp(); + + let mut ixn = IxnEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: icp.i.clone(), + s: KeriSequence::new(5), + p: icp.d.clone(), + a: vec![], + x: String::new(), + }; + + let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&json); + + let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); + let sig = keypair.sign(&canonical); + ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + let events = vec![Event::Icp(icp), Event::Ixn(ixn)]; + let result = validate_kel(&events); + assert!(matches!( + result, + Err(ValidationError::InvalidSequence { + expected: 1, + actual: 5 + }) + )); + } + + #[test] + fn rejects_broken_chain() { + let (icp, keypair) = make_signed_icp(); + + let mut ixn = IxnEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: icp.i.clone(), + s: KeriSequence::new(1), + p: Said::new_unchecked("EWrongPrevious".to_string()), + a: vec![], + x: String::new(), + }; + + let json = serde_json::to_vec(&Event::Ixn(ixn.clone())).unwrap(); + ixn.d = compute_said(&json); + + let canonical = serialize_for_signing(&Event::Ixn(ixn.clone())).unwrap(); + let sig = keypair.sign(&canonical); + ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + let events = vec![Event::Icp(icp), Event::Ixn(ixn)]; + let result = validate_kel(&events); + assert!(matches!(result, Err(ValidationError::BrokenChain { .. }))); + } + + #[test] + fn rejects_invalid_said() { + let icp = make_raw_icp("DKey1", "ENext1"); + let finalized = finalize_icp_event(icp).unwrap(); + + let mut tampered = finalized.clone(); + tampered.d = Said::new_unchecked("EWrongSaid".to_string()); + + let events = vec![Event::Icp(tampered)]; + let result = validate_kel(&events); + assert!(matches!(result, Err(ValidationError::InvalidSaid { .. }))); + } + + #[test] + fn validates_icp_then_ixn() { + let (icp, keypair) = make_signed_icp(); + let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); + + let events = vec![Event::Icp(icp), Event::Ixn(ixn.clone())]; + let state = validate_kel(&events).unwrap(); + assert_eq!(state.sequence, 1); + assert_eq!(state.last_event_said, ixn.d); + } + + #[test] + fn compute_event_said_works() { + let icp = make_raw_icp("DKey1", "ENext1"); + let event = Event::Icp(icp); + let said = compute_event_said(&event).unwrap(); + assert!(said.as_str().starts_with('E')); + assert!(!said.is_empty()); + } + + #[test] + fn rejects_forged_signature() { + let (mut icp, _keypair) = make_signed_icp(); + icp.x = URL_SAFE_NO_PAD.encode([0u8; 64]); + + let events = vec![Event::Icp(icp)]; + let result = validate_kel(&events); + assert!(matches!( + result, + Err(ValidationError::SignatureFailed { sequence: 0 }) + )); + } + + #[test] + fn rejects_missing_signature() { + let (mut icp, _keypair) = make_signed_icp(); + icp.x = String::new(); + + let events = vec![Event::Icp(icp)]; + let result = validate_kel(&events); + assert!(matches!( + result, + Err(ValidationError::SignatureFailed { sequence: 0 }) + )); + } + + #[test] + fn rejects_wrong_key_signature() { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); + + let mut icp = IcpEvent { + v: KERI_VERSION.to_string(), + d: Said::default(), + i: Prefix::default(), + s: KeriSequence::new(0), + kt: "1".to_string(), + k: vec![key_encoded], + nt: "1".to_string(), + n: vec!["ENextCommitment".to_string()], + bt: "0".to_string(), + b: vec![], + a: vec![], + x: String::new(), + }; + + icp = finalize_icp_event(icp).unwrap(); + + let wrong_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let wrong_keypair = Ed25519KeyPair::from_pkcs8(wrong_pkcs8.as_ref()).unwrap(); + let canonical = serialize_for_signing(&Event::Icp(icp.clone())).unwrap(); + let sig = wrong_keypair.sign(&canonical); + icp.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); + + let events = vec![Event::Icp(icp)]; + let result = validate_kel(&events); + assert!(matches!( + result, + Err(ValidationError::SignatureFailed { sequence: 0 }) + )); + } + + #[test] + fn crypto_accepts_valid_inception() { + let (icp, _keypair) = make_signed_icp(); + let result = verify_event_crypto(&Event::Icp(icp), None); + assert!(result.is_ok()); + } + + #[test] + fn find_seal_in_kel_finds_digest() { + let (icp, keypair) = make_signed_icp(); + let ixn = make_signed_ixn(&icp.i, &icp.d, 1, &keypair); + let events = vec![Event::Icp(icp), Event::Ixn(ixn)]; + assert_eq!(find_seal_in_kel(&events, "EAttest"), Some(1)); + assert_eq!(find_seal_in_kel(&events, "ENonExistent"), None); + } + + #[test] + fn parse_kel_json_rejects_invalid_hex_sequence() { + let json = r#"[{"v":"KERI10JSON","t":"icp","i":"E123","s":"not_hex","kt":"1","k":["DKey"],"nt":"1","n":["ENext"],"bt":"0","b":[]}]"#; + let result = parse_kel_json(json); + assert!(result.is_err(), "expected error for invalid hex sequence"); + } +} diff --git a/crates/auths-keri/src/witness/async_provider.rs b/crates/auths-keri/src/witness/async_provider.rs new file mode 100644 index 00000000..f9644d92 --- /dev/null +++ b/crates/auths-keri/src/witness/async_provider.rs @@ -0,0 +1,253 @@ +//! Async witness provider trait for network-based witness operations. +//! +//! This module defines the async version of the witness provider trait, +//! designed for network-based witness interactions that require async I/O. +//! +//! # Design Rationale +//! +//! The sync [`WitnessProvider`] trait is preserved for backward compatibility +//! and for use cases where blocking is acceptable (e.g., local caching). +//! This async trait is designed for: +//! +//! - HTTP-based witness servers +//! - Network I/O with configurable timeouts +//! - Parallel receipt collection from multiple witnesses +//! +//! # Example +//! +//! ```rust,ignore +//! use auths_keri::witness::{AsyncWitnessProvider, Receipt, WitnessError, EventHash}; +//! use auths_keri::{Prefix, Said}; +//! use async_trait::async_trait; +//! +//! struct HttpWitness { +//! base_url: String, +//! timeout_ms: u64, +//! } +//! +//! #[async_trait] +//! impl AsyncWitnessProvider for HttpWitness { +//! async fn submit_event(&self, prefix: &Prefix, event_json: &[u8]) -> Result { +//! todo!() +//! } +//! +//! async fn observe_identity_head(&self, prefix: &Prefix) -> Result, WitnessError> { +//! todo!() +//! } +//! +//! async fn get_receipt(&self, prefix: &Prefix, event_said: &Said) -> Result, WitnessError> { +//! todo!() +//! } +//! } +//! ``` + +use crate::{Prefix, Said}; +use async_trait::async_trait; + +use super::error::WitnessError; +use super::hash::EventHash; +use super::receipt::Receipt; + +/// Async witness provider for network-based witness operations. +/// +/// This trait defines the interface for interacting with witness servers +/// asynchronously. Implementations typically communicate over HTTP with +/// witness infrastructure. +/// +/// # Thread Safety +/// +/// Implementations must be `Send + Sync` to allow use in async contexts +/// across multiple tasks. +/// +/// # Error Handling +/// +/// All methods return `Result` to enable proper error +/// propagation and handling of network failures, timeouts, and security +/// violations (like duplicity detection). +#[async_trait] +pub trait AsyncWitnessProvider: Send + Sync { + /// Submit an event to the witness for receipting. + /// + /// The witness will: + /// 1. Parse and validate the event + /// 2. Check for duplicity (same prefix+seq with different SAID) + /// 3. If valid and not duplicate, sign and return a receipt + /// + /// # Arguments + /// + /// * `prefix` - The KERI prefix of the identity + /// * `event_json` - The canonicalized JSON bytes of the event + /// + /// # Returns + /// + /// * `Ok(Receipt)` - The witness accepted the event and issued a receipt + /// * `Err(WitnessError::Duplicity(_))` - Duplicity detected (split-view attack) + /// * `Err(WitnessError::Rejected { .. })` - Event rejected (invalid format, etc.) + /// * `Err(WitnessError::Network(_))` - Network error + /// * `Err(WitnessError::Timeout(_))` - Operation timed out + async fn submit_event( + &self, + prefix: &Prefix, + event_json: &[u8], + ) -> Result; + + /// Query the current observed head for an identity. + /// + /// Returns the hash of the most recent event the witness has observed + /// for the given identity prefix. + /// + /// # Arguments + /// + /// * `prefix` - The KERI prefix of the identity + /// + /// # Returns + /// + /// * `Ok(Some(hash))` - The witness has an observed head for this identity + /// * `Ok(None)` - The witness has not observed any events for this identity + /// * `Err(_)` - Error during query + async fn observe_identity_head( + &self, + prefix: &Prefix, + ) -> Result, WitnessError>; + + /// Retrieve a previously issued receipt. + /// + /// # Arguments + /// + /// * `prefix` - The KERI prefix of the identity + /// * `event_said` - The SAID of the event to get the receipt for + /// + /// # Returns + /// + /// * `Ok(Some(receipt))` - Receipt found + /// * `Ok(None)` - No receipt found for this event + /// * `Err(_)` - Error during retrieval + async fn get_receipt( + &self, + prefix: &Prefix, + event_said: &Said, + ) -> Result, WitnessError>; + + /// Get the minimum quorum required for consistency. + /// + /// When using a multi-witness setup, this specifies how many witnesses + /// must agree for the event to be considered properly witnessed. + /// + /// # Default + /// + /// Returns `1` (single witness is sufficient). + fn quorum(&self) -> usize { + 1 + } + + /// Get the timeout for operations in milliseconds. + /// + /// # Default + /// + /// Returns `5000` (5 seconds). + fn timeout_ms(&self) -> u64 { + 5000 + } + + /// Check if this provider is currently available. + /// + /// # Default + /// + /// Returns `Ok(true)`. Implementations may override to perform actual + /// health checks. + async fn is_available(&self) -> Result { + Ok(true) + } +} + +/// A no-op async witness provider that always succeeds without doing anything. +/// +/// This is useful for testing or when witness functionality is disabled. +#[derive(Debug, Clone, Default)] +pub struct NoOpAsyncWitness; + +#[async_trait] +impl AsyncWitnessProvider for NoOpAsyncWitness { + async fn submit_event( + &self, + _prefix: &Prefix, + _event_json: &[u8], + ) -> Result { + Ok(Receipt { + v: super::receipt::KERI_VERSION.into(), + t: super::receipt::RECEIPT_TYPE.into(), + d: Said::new_unchecked("ENoop".into()), + i: "did:key:noop".into(), + s: 0, + a: Said::new_unchecked("ENoop".into()), + sig: vec![0u8; 64], + }) + } + + async fn observe_identity_head( + &self, + _prefix: &Prefix, + ) -> Result, WitnessError> { + Ok(None) + } + + async fn get_receipt( + &self, + _prefix: &Prefix, + _event_said: &Said, + ) -> Result, WitnessError> { + Ok(None) + } + + fn quorum(&self) -> usize { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn noop_witness_submit_returns_dummy_receipt() { + let witness = NoOpAsyncWitness; + let prefix = Prefix::new_unchecked("ETest".into()); + let receipt = witness.submit_event(&prefix, b"{}").await.unwrap(); + assert_eq!(receipt.t, "rct"); + } + + #[tokio::test] + async fn noop_witness_observe_returns_none() { + let witness = NoOpAsyncWitness; + let prefix = Prefix::new_unchecked("ETest".into()); + let head = witness.observe_identity_head(&prefix).await.unwrap(); + assert!(head.is_none()); + } + + #[tokio::test] + async fn noop_witness_get_receipt_returns_none() { + let witness = NoOpAsyncWitness; + let prefix = Prefix::new_unchecked("ETest".into()); + let said = Said::new_unchecked("ESAID".into()); + let receipt = witness.get_receipt(&prefix, &said).await.unwrap(); + assert!(receipt.is_none()); + } + + #[tokio::test] + async fn noop_witness_quorum_is_zero() { + let witness = NoOpAsyncWitness; + assert_eq!(witness.quorum(), 0); + } + + #[tokio::test] + async fn noop_witness_is_available() { + let witness = NoOpAsyncWitness; + assert!(witness.is_available().await.unwrap()); + } + + #[test] + fn default_timeout() { + let witness = NoOpAsyncWitness; + assert_eq!(witness.timeout_ms(), 5000); + } +} diff --git a/crates/auths-keri/src/witness/error.rs b/crates/auths-keri/src/witness/error.rs new file mode 100644 index 00000000..9cf23b6c --- /dev/null +++ b/crates/auths-keri/src/witness/error.rs @@ -0,0 +1,202 @@ +//! Error types for witness operations. +//! +//! This module defines the error types used by the async witness infrastructure, +//! including duplicity evidence for split-view detection. + +use crate::{Prefix, Said}; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Evidence of duplicity (split-view attack) detected by witnesses. +/// +/// When a controller presents different events with the same (prefix, seq) +/// to different witnesses, this evidence captures the conflicting SAIDs. +/// +/// # Fields +/// +/// - `prefix`: The KERI prefix of the identity +/// - `sequence`: The sequence number where duplicity was detected +/// - `event_a_said`: SAID of the first event seen +/// - `event_b_said`: SAID of the conflicting event +/// - `witness_reports`: Reports from witnesses that observed the conflict +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DuplicityEvidence { + /// The KERI prefix of the identity + pub prefix: Prefix, + /// The sequence number where duplicity was detected + pub sequence: u64, + /// SAID of the first event seen (the "canonical" one) + pub event_a_said: Said, + /// SAID of the conflicting event + pub event_b_said: Said, + /// Reports from individual witnesses + pub witness_reports: Vec, +} + +impl fmt::Display for DuplicityEvidence { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Duplicity detected for {} at seq {}: {} vs {}", + self.prefix, self.sequence, self.event_a_said, self.event_b_said + ) + } +} + +/// A report from a single witness about what it observed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WitnessReport { + /// The witness identifier (DID) + pub witness_id: String, + /// The SAID this witness observed for the (prefix, seq) + pub observed_said: Said, + /// When this observation was made (ISO 8601) + pub observed_at: Option, +} + +/// Errors that can occur during witness operations. +/// +/// These errors cover the full range of failure modes for async witness +/// interactions, from network issues to security violations. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WitnessError { + /// Network error communicating with witness. + #[error("network error: {0}")] + Network(String), + + /// Duplicity detected - the controller presented different events. + /// + /// This is a **security violation** indicating a potential split-view attack. + #[error("duplicity detected: {0}")] + Duplicity(DuplicityEvidence), + + /// The witness rejected the event. + /// + /// This can happen if the event is malformed, the witness doesn't track + /// this identity, or the event fails validation. + #[error("event rejected: {reason}")] + Rejected { + /// Human-readable reason for rejection + reason: String, + }, + + /// Operation timed out. + #[error("timeout after {0}ms")] + Timeout(u64), + + /// Invalid receipt signature. + #[error("invalid receipt signature from witness {witness_id}")] + InvalidSignature { + /// The witness that provided the invalid signature + witness_id: String, + }, + + /// Insufficient receipts to meet threshold. + #[error("insufficient receipts: got {got}, need {required}")] + InsufficientReceipts { + /// Number of receipts received + got: usize, + /// Number of receipts required + required: usize, + }, + + /// Receipt is for wrong event. + #[error("receipt SAID mismatch: expected {expected}, got {got}")] + SaidMismatch { + /// Expected event SAID + expected: Said, + /// Actual SAID in receipt + got: Said, + }, + + /// Storage error. + #[error("storage error: {0}")] + Storage(String), + + /// Serialization error. + #[error("serialization error: {0}")] + Serialization(String), +} + +impl auths_crypto::AuthsErrorInfo for WitnessError { + fn error_code(&self) -> &'static str { + match self { + Self::Network(_) => "AUTHS-E3401", + Self::Duplicity(_) => "AUTHS-E3402", + Self::Rejected { .. } => "AUTHS-E3403", + Self::Timeout(_) => "AUTHS-E3404", + Self::InvalidSignature { .. } => "AUTHS-E3405", + Self::InsufficientReceipts { .. } => "AUTHS-E3406", + Self::SaidMismatch { .. } => "AUTHS-E3407", + Self::Storage(_) => "AUTHS-E3408", + Self::Serialization(_) => "AUTHS-E3409", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::Duplicity(_) => { + Some("This identity may be compromised — investigate immediately") + } + Self::Timeout(_) => Some("Check witness endpoint availability and retry"), + Self::InsufficientReceipts { .. } => Some("Ensure enough witnesses are online"), + Self::Network(_) => Some("Check your internet connection"), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duplicity_evidence_display() { + let evidence = DuplicityEvidence { + prefix: Prefix::new_unchecked("EPrefix123".into()), + sequence: 5, + event_a_said: Said::new_unchecked("ESAID_A".into()), + event_b_said: Said::new_unchecked("ESAID_B".into()), + witness_reports: vec![], + }; + let display = format!("{}", evidence); + assert!(display.contains("EPrefix123")); + assert!(display.contains("5")); + assert!(display.contains("ESAID_A")); + assert!(display.contains("ESAID_B")); + } + + #[test] + fn witness_error_variants() { + let network_err = WitnessError::Network("connection refused".into()); + assert!(format!("{}", network_err).contains("network error")); + + let timeout_err = WitnessError::Timeout(5000); + assert!(format!("{}", timeout_err).contains("5000ms")); + + let rejected_err = WitnessError::Rejected { + reason: "invalid format".into(), + }; + assert!(format!("{}", rejected_err).contains("invalid format")); + } + + #[test] + fn duplicity_evidence_serialization() { + let evidence = DuplicityEvidence { + prefix: Prefix::new_unchecked("EPrefix123".into()), + sequence: 5, + event_a_said: Said::new_unchecked("ESAID_A".into()), + event_b_said: Said::new_unchecked("ESAID_B".into()), + witness_reports: vec![WitnessReport { + witness_id: "did:key:witness1".into(), + observed_said: Said::new_unchecked("ESAID_A".into()), + observed_at: Some("2024-01-01T00:00:00Z".into()), + }], + }; + + let json = serde_json::to_string(&evidence).unwrap(); + let parsed: DuplicityEvidence = serde_json::from_str(&json).unwrap(); + assert_eq!(evidence, parsed); + } +} diff --git a/crates/auths-keri/src/witness/hash.rs b/crates/auths-keri/src/witness/hash.rs new file mode 100644 index 00000000..942a2138 --- /dev/null +++ b/crates/auths-keri/src/witness/hash.rs @@ -0,0 +1,307 @@ +//! Backend-agnostic event hash type. +//! +//! This module provides [`EventHash`], a 20-byte hash type used to identify +//! KEL events without depending on any specific storage backend (e.g., git2). +//! +//! # Why 20 Bytes? +//! +//! Git uses SHA-1 (20 bytes) for object identifiers. This type is sized to +//! be compatible with Git OIDs while remaining backend-agnostic. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +/// A 20-byte hash identifying a KEL event. +/// +/// Serializes as a 40-character lowercase hex string, matching the encoding +/// used by `git2::Oid::to_string()`. This ensures JSON payloads, API schemas, +/// and cache files remain compatible when migrating from `git2::Oid`. +/// +/// # Args +/// +/// The inner `[u8; 20]` represents the raw SHA-1 bytes. +/// +/// # Usage +/// +/// ```rust +/// use auths_keri::witness::EventHash; +/// +/// // From raw bytes +/// let bytes = [0u8; 20]; +/// let hash = EventHash::from_bytes(bytes); +/// assert_eq!(hash.as_bytes(), &bytes); +/// +/// // From hex string +/// let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); +/// assert_eq!(hash.to_hex(), "0000000000000000000000000000000000000001"); +/// +/// // Serde: serializes as hex string, not integer array +/// let json = serde_json::to_string(&hash).unwrap(); +/// assert_eq!(json, r#""0000000000000000000000000000000000000001""#); +/// ``` +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct EventHash([u8; 20]); + +impl EventHash { + /// Create an EventHash from raw bytes. + #[inline] + pub const fn from_bytes(bytes: [u8; 20]) -> Self { + Self(bytes) + } + + /// Get the raw bytes of this hash. + #[inline] + pub fn as_bytes(&self) -> &[u8; 20] { + &self.0 + } + + /// Create an EventHash from a hex string. + /// + /// Returns `None` if the string is not exactly 40 hex characters. + /// + /// # Example + /// + /// ```rust + /// use auths_keri::witness::EventHash; + /// + /// let hash = EventHash::from_hex("0123456789abcdef0123456789abcdef01234567"); + /// assert!(hash.is_some()); + /// + /// // Wrong length + /// assert!(EventHash::from_hex("0123").is_none()); + /// + /// // Invalid characters + /// assert!(EventHash::from_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none()); + /// ``` + pub fn from_hex(s: &str) -> Option { + if s.len() != 40 { + return None; + } + + let mut bytes = [0u8; 20]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let hi = hex_digit(chunk[0])?; + let lo = hex_digit(chunk[1])?; + bytes[i] = (hi << 4) | lo; + } + + Some(Self(bytes)) + } + + /// Convert this hash to a lowercase hex string. + /// + /// # Example + /// + /// ```rust + /// use auths_keri::witness::EventHash; + /// + /// let hash = EventHash::from_bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + /// 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + /// assert_eq!(hash.to_hex(), "000102030405060708090a0b0c0d0e0f10111213"); + /// ``` + pub fn to_hex(&self) -> String { + let mut s = String::with_capacity(40); + for byte in &self.0 { + s.push(HEX_CHARS[(byte >> 4) as usize]); + s.push(HEX_CHARS[(byte & 0xf) as usize]); + } + s + } +} + +/// Hex characters for encoding. +const HEX_CHARS: [char; 16] = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', +]; + +/// Convert a hex character to its numeric value. +#[inline] +fn hex_digit(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +impl fmt::Debug for EventHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "EventHash({})", self.to_hex()) + } +} + +impl fmt::Display for EventHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_hex()) + } +} + +/// Error returned when parsing an `EventHash` from a hex string fails. +/// +/// # Args +/// +/// * `InvalidLength` — the input was not exactly 40 hex characters +/// * `InvalidChar` — the input contained a non-hex character +/// +/// # Usage +/// +/// ```rust +/// use auths_keri::witness::EventHash; +/// use std::str::FromStr; +/// +/// assert!(EventHash::from_str("not-hex").is_err()); +/// ``` +#[derive(Debug, thiserror::Error, PartialEq)] +#[non_exhaustive] +pub enum EventHashParseError { + /// The input string was not exactly 40 hex characters. + #[error("expected 40 hex characters, got {0}")] + InvalidLength(usize), + /// The input contained a non-hex character at the given position. + #[error("invalid hex character at position {position}: {ch:?}")] + InvalidChar { + /// Zero-based index of the first invalid character. + position: usize, + /// The character that failed hex decoding. + ch: char, + }, +} + +impl FromStr for EventHash { + type Err = EventHashParseError; + + fn from_str(s: &str) -> Result { + if s.len() != 40 { + return Err(EventHashParseError::InvalidLength(s.len())); + } + let mut bytes = [0u8; 20]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let hi = hex_digit(chunk[0]).ok_or(EventHashParseError::InvalidChar { + position: i * 2, + ch: chunk[0] as char, + })?; + let lo = hex_digit(chunk[1]).ok_or(EventHashParseError::InvalidChar { + position: i * 2 + 1, + ch: chunk[1] as char, + })?; + bytes[i] = (hi << 4) | lo; + } + Ok(Self(bytes)) + } +} + +impl Serialize for EventHash { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_hex()) + } +} + +impl<'de> Deserialize<'de> for EventHash { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse::().map_err(de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_bytes_roundtrip() { + let bytes = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + ]; + let hash = EventHash::from_bytes(bytes); + assert_eq!(hash.as_bytes(), &bytes); + } + + #[test] + fn from_hex_valid() { + let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); + let mut expected = [0u8; 20]; + expected[19] = 1; + assert_eq!(hash.as_bytes(), &expected); + } + + #[test] + fn from_hex_all_zeros() { + let hash = EventHash::from_hex("0000000000000000000000000000000000000000").unwrap(); + assert_eq!(hash.as_bytes(), &[0u8; 20]); + } + + #[test] + fn from_hex_uppercase() { + let hash = EventHash::from_hex("ABCDEF0123456789ABCDEF0123456789ABCDEF01").unwrap(); + assert!( + hash.to_hex() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + ); + } + + #[test] + fn from_hex_wrong_length() { + assert!(EventHash::from_hex("0123").is_none()); + assert!(EventHash::from_hex("").is_none()); + assert!(EventHash::from_hex("00000000000000000000000000000000000000001").is_none()); + } + + #[test] + fn from_hex_invalid_chars() { + assert!(EventHash::from_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none()); + assert!(EventHash::from_hex("0000000000000000000000000000000000000g01").is_none()); + } + + #[test] + fn to_hex_roundtrip() { + let original = "0123456789abcdef0123456789abcdef01234567"; + let hash = EventHash::from_hex(original).unwrap(); + assert_eq!(hash.to_hex(), original); + } + + #[test] + fn debug_format() { + let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); + let debug = format!("{:?}", hash); + assert!(debug.contains("EventHash")); + assert!(debug.contains("0000000000000000000000000000000000000001")); + } + + #[test] + fn display_format() { + let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); + assert_eq!( + format!("{}", hash), + "0000000000000000000000000000000000000001" + ); + } + + #[test] + fn equality() { + let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); + let b = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); + let c = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap(); + + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn hash_trait() { + use std::collections::HashSet; + + let mut set = HashSet::new(); + let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap(); + let b = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap(); + + set.insert(a); + set.insert(b); + set.insert(a); // duplicate + + assert_eq!(set.len(), 2); + } +} diff --git a/crates/auths-keri/src/witness/mod.rs b/crates/auths-keri/src/witness/mod.rs new file mode 100644 index 00000000..39401d5e --- /dev/null +++ b/crates/auths-keri/src/witness/mod.rs @@ -0,0 +1,11 @@ +mod async_provider; +mod error; +mod hash; +mod provider; +mod receipt; + +pub use async_provider::{AsyncWitnessProvider, NoOpAsyncWitness}; +pub use error::{DuplicityEvidence, WitnessError, WitnessReport}; +pub use hash::{EventHash, EventHashParseError}; +pub use provider::WitnessProvider; +pub use receipt::{KERI_VERSION, RECEIPT_TYPE, Receipt, ReceiptBuilder}; diff --git a/crates/auths-keri/src/witness/provider.rs b/crates/auths-keri/src/witness/provider.rs new file mode 100644 index 00000000..661b1443 --- /dev/null +++ b/crates/auths-keri/src/witness/provider.rs @@ -0,0 +1,119 @@ +//! Witness provider trait. + +use crate::Prefix; + +use super::hash::EventHash; + +/// A provider that observes identity KEL heads for split-view detection. +/// +/// Implementations of this trait act as "witnesses" that can report +/// what they believe to be the current head of an identity's KEL. +/// +/// # Thread Safety +/// +/// Implementations must be `Send + Sync` to allow use across threads. +/// This is required because policy evaluation may happen in async contexts. +/// +/// # No Networking in Trait +/// +/// Note that this trait definition contains no networking code. Implementations +/// may use networking internally (e.g., to query remote witnesses), but the +/// trait itself is pure and synchronous. +/// +/// # Example +/// +/// ```rust,ignore +/// use auths_keri::witness::{WitnessProvider, EventHash}; +/// use auths_keri::Prefix; +/// +/// struct MyWitness; +/// impl WitnessProvider for MyWitness { +/// fn observe_identity_head(&self, prefix: &Prefix) -> Option { +/// EventHash::from_hex("0123456789abcdef0123456789abcdef01234567") +/// } +/// } +/// ``` +pub trait WitnessProvider: Send + Sync { + /// Observe the current head of an identity's KEL. + /// + /// Returns the hash of the most recent event the witness has seen + /// for the given identity prefix. + /// + /// # Returns + /// + /// - `Some(hash)` - The witness has an opinion on this identity's head + /// - `None` - The witness has no opinion (offline, not tracking, or disabled) + /// + /// # Arguments + /// + /// * `prefix` - The KERI prefix of the identity (e.g., "E123abc...") + fn observe_identity_head(&self, prefix: &Prefix) -> Option; + + /// Get the minimum quorum required for consistency. + /// + /// When multiple witnesses are used, this specifies how many must agree + /// for the head to be considered consistent. + /// + /// # Default + /// + /// Returns `1` (single witness is sufficient). + fn quorum(&self) -> usize { + 1 + } + + /// Check if this witness is enabled. + /// + /// # Default + /// + /// Returns `true`. Override to return `false` for no-op implementations. + fn is_enabled(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockWitness { + head: Option, + quorum: usize, + } + + impl WitnessProvider for MockWitness { + fn observe_identity_head(&self, _prefix: &Prefix) -> Option { + self.head + } + + fn quorum(&self) -> usize { + self.quorum + } + } + + #[test] + fn test_default_quorum() { + let witness = MockWitness { + head: None, + quorum: 1, + }; + assert_eq!(witness.quorum(), 1); + } + + #[test] + fn test_custom_quorum() { + let witness = MockWitness { + head: None, + quorum: 3, + }; + assert_eq!(witness.quorum(), 3); + } + + #[test] + fn test_is_enabled_default() { + let witness = MockWitness { + head: None, + quorum: 1, + }; + assert!(witness.is_enabled()); + } +} diff --git a/crates/auths-keri/src/witness/receipt.rs b/crates/auths-keri/src/witness/receipt.rs new file mode 100644 index 00000000..177e63f5 --- /dev/null +++ b/crates/auths-keri/src/witness/receipt.rs @@ -0,0 +1,336 @@ +//! Witness receipt type for KERI event witnessing. +//! +//! A receipt is a signed acknowledgment from a witness that it has observed +//! a specific KEL event. Receipts enable duplicity detection by allowing +//! verifiers to check that witnesses agree on the event history. +//! +//! # KERI Receipt Format +//! +//! This implementation follows the KERI `rct` (non-transferable receipt) format: +//! +//! ```json +//! { +//! "v": "KERI10JSON...", +//! "t": "rct", +//! "d": "", +//! "i": "", +//! "s": "", +//! "a": "", +//! "sig": "" +//! } +//! ``` + +use crate::Said; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; + +/// KERI version string for receipts. +pub const KERI_VERSION: &str = "KERI10JSON000000_"; + +/// Receipt type identifier. +pub const RECEIPT_TYPE: &str = "rct"; + +/// A witness receipt for a KEL event. +/// +/// The receipt proves that a witness has observed and acknowledged a specific +/// event. It includes the witness's signature over the event SAID, enabling +/// verifiers to check receipt authenticity. +/// +/// # Serialization +/// +/// The `sig` field uses hex encoding for JSON serialization. +/// +/// # Example +/// +/// ```rust +/// use auths_keri::witness::Receipt; +/// use auths_keri::Said; +/// +/// let receipt = Receipt { +/// v: "KERI10JSON000000_".into(), +/// t: "rct".into(), +/// d: Said::new_unchecked("EReceipt123".into()), +/// i: "did:key:z6MkWitness...".into(), +/// s: 5, +/// a: Said::new_unchecked("EEvent456".into()), +/// sig: vec![0u8; 64], +/// }; +/// +/// let json = serde_json::to_string(&receipt).unwrap(); +/// let parsed: Receipt = serde_json::from_str(&json).unwrap(); +/// assert_eq!(receipt.s, parsed.s); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Receipt { + /// Version string (e.g., "KERI10JSON000000_") + pub v: String, + + /// Type identifier ("rct" for receipt) + pub t: String, + + /// Receipt SAID (Self-Addressing Identifier) + pub d: Said, + + /// Witness identifier (DID) + pub i: String, + + /// Event sequence number being receipted + pub s: u64, + + /// Event SAID being receipted + pub a: Said, + + /// Ed25519 signature over the canonical receipt JSON (excluding sig) + #[serde(with = "hex")] + pub sig: Vec, +} + +impl Receipt { + /// Create a new receipt builder. + pub fn builder() -> ReceiptBuilder { + ReceiptBuilder::new() + } + + /// Check if this receipt is for the given event SAID. + pub fn is_for_event(&self, event_said: &Said) -> bool { + self.a == *event_said + } + + /// Check if this receipt is from the given witness. + pub fn is_from_witness(&self, witness_id: &str) -> bool { + self.i == witness_id + } + + /// Formats this receipt as a Git trailer value (base64url-encoded JSON). + pub fn to_trailer_value(&self) -> Result { + let json = serde_json::to_string(self)?; + Ok(URL_SAFE_NO_PAD.encode(json.as_bytes())) + } + + /// Parses a receipt from a Git trailer value (base64url-encoded JSON). + /// + /// Strips all whitespace before decoding to handle RFC 822 line folding, + /// which may introduce spaces between base64url chunks during unfolding. + pub fn from_trailer_value(value: &str) -> Result { + let clean: String = value.split_whitespace().collect(); + let bytes = URL_SAFE_NO_PAD + .decode(&clean) + .map_err(|e| format!("base64 decode failed: {}", e))?; + serde_json::from_slice(&bytes).map_err(|e| format!("json parse failed: {}", e)) + } + + /// Get the canonical JSON for signing (without the sig field). + /// + /// This produces the JSON that should be signed to create the receipt. + pub fn signing_payload(&self) -> Result, serde_json::Error> { + let payload = ReceiptSigningPayload { + v: &self.v, + t: &self.t, + d: &self.d, + i: &self.i, + s: self.s, + a: &self.a, + }; + serde_json::to_vec(&payload) + } +} + +/// Internal type for signing payload (excludes sig). +#[derive(Serialize)] +struct ReceiptSigningPayload<'a> { + v: &'a str, + t: &'a str, + d: &'a Said, + i: &'a str, + s: u64, + a: &'a Said, +} + +/// Builder for constructing receipts. +#[derive(Debug, Default)] +pub struct ReceiptBuilder { + v: Option, + d: Option, + i: Option, + s: Option, + a: Option, + sig: Option>, +} + +impl ReceiptBuilder { + /// Create a new receipt builder with defaults. + pub fn new() -> Self { + Self { + v: Some(KERI_VERSION.into()), + ..Default::default() + } + } + + /// Set the receipt SAID. + pub fn said(mut self, said: Said) -> Self { + self.d = Some(said); + self + } + + /// Set the witness identifier. + pub fn witness(mut self, witness_id: impl Into) -> Self { + self.i = Some(witness_id.into()); + self + } + + /// Set the event sequence number. + pub fn sequence(mut self, seq: u64) -> Self { + self.s = Some(seq); + self + } + + /// Set the event SAID being receipted. + pub fn event_said(mut self, event_said: Said) -> Self { + self.a = Some(event_said); + self + } + + /// Set the signature. + pub fn signature(mut self, sig: Vec) -> Self { + self.sig = Some(sig); + self + } + + /// Build the receipt. + /// + /// Returns `None` if required fields are missing. + pub fn build(self) -> Option { + Some(Receipt { + v: self.v?, + t: RECEIPT_TYPE.into(), + d: self.d?, + i: self.i?, + s: self.s?, + a: self.a?, + sig: self.sig?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + + fn sample_receipt() -> Receipt { + Receipt { + v: KERI_VERSION.into(), + t: RECEIPT_TYPE.into(), + d: Said::new_unchecked("EReceipt123".into()), + i: "did:key:z6MkWitness".into(), + s: 5, + a: Said::new_unchecked("EEvent456".into()), + sig: vec![0xab; 64], + } + } + + #[test] + fn receipt_serialization_roundtrip() { + let receipt = sample_receipt(); + let json = serde_json::to_string(&receipt).unwrap(); + let parsed: Receipt = serde_json::from_str(&json).unwrap(); + assert_eq!(receipt, parsed); + } + + #[test] + fn receipt_sig_hex_encoded() { + let receipt = sample_receipt(); + let json = serde_json::to_string(&receipt).unwrap(); + assert!(json.contains(&"ab".repeat(64))); + } + + #[test] + fn receipt_is_for_event() { + let receipt = sample_receipt(); + assert!(receipt.is_for_event(&Said::new_unchecked("EEvent456".into()))); + assert!(!receipt.is_for_event(&Said::new_unchecked("EWrongEvent".into()))); + } + + #[test] + fn receipt_is_from_witness() { + let receipt = sample_receipt(); + assert!(receipt.is_from_witness("did:key:z6MkWitness")); + assert!(!receipt.is_from_witness("did:key:z6MkOther")); + } + + #[test] + fn receipt_signing_payload() { + let receipt = sample_receipt(); + let payload = receipt.signing_payload().unwrap(); + let payload_str = String::from_utf8(payload).unwrap(); + + assert!(!payload_str.contains("sig")); + assert!(payload_str.contains("EReceipt123")); + assert!(payload_str.contains("did:key:z6MkWitness")); + } + + #[test] + fn receipt_builder() { + let receipt = Receipt::builder() + .said(Said::new_unchecked("EReceipt123".into())) + .witness("did:key:z6MkWitness") + .sequence(5) + .event_said(Said::new_unchecked("EEvent456".into())) + .signature(vec![0u8; 64]) + .build() + .unwrap(); + + assert_eq!(receipt.v, KERI_VERSION); + assert_eq!(receipt.t, RECEIPT_TYPE); + assert_eq!(receipt.d, "EReceipt123"); + assert_eq!(receipt.s, 5); + } + + #[test] + fn receipt_builder_missing_fields() { + let result = Receipt::builder() + .said(Said::new_unchecked("EReceipt123".into())) + .build(); + assert!(result.is_none()); + } + + #[test] + fn receipt_json_structure() { + let receipt = sample_receipt(); + let json: serde_json::Value = serde_json::to_value(&receipt).unwrap(); + + assert_eq!(json["v"], KERI_VERSION); + assert_eq!(json["t"], RECEIPT_TYPE); + assert_eq!(json["s"], 5); + } + + #[test] + fn trailer_value_roundtrip() { + let receipt = sample_receipt(); + let encoded = receipt.to_trailer_value().unwrap(); + let decoded = Receipt::from_trailer_value(&encoded).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn trailer_value_is_base64url() { + let receipt = sample_receipt(); + let encoded = receipt.to_trailer_value().unwrap(); + assert!(!encoded.contains('=')); + assert!(!encoded.contains('+')); + assert!(!encoded.contains('/')); + } + + #[test] + fn from_trailer_value_invalid_base64() { + let result = Receipt::from_trailer_value("not-valid-base64!!!"); + assert!(result.is_err()); + } + + #[test] + fn from_trailer_value_invalid_json() { + let encoded = B64.encode(b"not json"); + let result = Receipt::from_trailer_value(&encoded); + assert!(result.is_err()); + } +} diff --git a/crates/auths-keri/tests/cases/event.rs b/crates/auths-keri/tests/cases/event.rs index d62575b5..d6acb61d 100644 --- a/crates/auths-keri/tests/cases/event.rs +++ b/crates/auths-keri/tests/cases/event.rs @@ -1,58 +1,57 @@ use auths_keri::{CesrV1Codec, serialize_for_cesr}; -use auths_verifier::keri::{IcpEvent, IxnEvent, KeriEvent, Prefix, RotEvent, Said, Seal}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -fn make_test_icp(sig: Option<&[u8; 64]>) -> KeriEvent { +fn make_test_icp(sig: Option<&[u8; 64]>) -> serde_json::Value { let x = sig.map(|s| URL_SAFE_NO_PAD.encode(s)).unwrap_or_default(); - KeriEvent::Inception(IcpEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "0".into(), - kt: "1".into(), - k: vec!["DTestKey12345678901234567890123456789012".into()], - nt: "1".into(), - n: vec!["ETestNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], - a: vec![], - x, + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "ETestSaid1234567890123456789012345678901", + "i": "ETestPrefix123456789012345678901234567890", + "s": "0", + "kt": "1", + "k": ["DTestKey12345678901234567890123456789012"], + "nt": "1", + "n": ["ETestNext12345678901234567890123456789012"], + "bt": "0", + "b": [], + "a": [], + "x": x }) } -fn make_test_rot(sig: Option<&[u8; 64]>) -> KeriEvent { +fn make_test_rot(sig: Option<&[u8; 64]>) -> serde_json::Value { let x = sig.map(|s| URL_SAFE_NO_PAD.encode(s)).unwrap_or_default(); - KeriEvent::Rotation(RotEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "1".into(), - p: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - kt: "1".into(), - k: vec!["DNewKey123456789012345678901234567890123".into()], - nt: "1".into(), - n: vec!["ENewNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], - a: vec![], - x, + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "rot", + "d": "ETestRotSaid23456789012345678901234567890", + "i": "ETestPrefix123456789012345678901234567890", + "s": "1", + "p": "ETestSaid1234567890123456789012345678901", + "kt": "1", + "k": ["DNewKey123456789012345678901234567890123"], + "nt": "1", + "n": ["ENewNext12345678901234567890123456789012"], + "bt": "0", + "b": [], + "a": [], + "x": x }) } -fn make_test_ixn(sig: Option<&[u8; 64]>) -> KeriEvent { +fn make_test_ixn(sig: Option<&[u8; 64]>) -> serde_json::Value { let x = sig.map(|s| URL_SAFE_NO_PAD.encode(s)).unwrap_or_default(); - KeriEvent::Interaction(IxnEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestIxnSaid23456789012345678901234567890".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "2".into(), - p: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - a: vec![Seal { - d: Said::new_unchecked("ESealDigest234567890123456789012345678901".into()), - seal_type: "device-attestation".into(), - }], - x, + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "ixn", + "d": "ETestIxnSaid23456789012345678901234567890", + "i": "ETestPrefix123456789012345678901234567890", + "s": "2", + "p": "ETestRotSaid23456789012345678901234567890", + "a": [{"d": "ESealDigest234567890123456789012345678901", "type": "device-attestation"}], + "x": x }) } @@ -170,7 +169,6 @@ fn rot_serialization_produces_valid_output() { let d = body.get("d").and_then(|v| v.as_str()).unwrap(); assert_eq!(d, result.said); - // For rotation, i should NOT equal d (it keeps the original prefix). let i = body.get("i").and_then(|v| v.as_str()).unwrap(); assert_ne!(i, result.said, "rotation i should keep original prefix"); diff --git a/crates/auths-keri/tests/cases/mod.rs b/crates/auths-keri/tests/cases/mod.rs index eb00a499..21a81693 100644 --- a/crates/auths-keri/tests/cases/mod.rs +++ b/crates/auths-keri/tests/cases/mod.rs @@ -1,4 +1,8 @@ +#[cfg(feature = "cesr")] mod codec; +#[cfg(feature = "cesr")] mod event; +#[cfg(feature = "cesr")] mod roundtrip; +#[cfg(feature = "cesr")] mod stream; diff --git a/crates/auths-keri/tests/cases/roundtrip.rs b/crates/auths-keri/tests/cases/roundtrip.rs index 9dc39d88..e1fdb8bf 100644 --- a/crates/auths-keri/tests/cases/roundtrip.rs +++ b/crates/auths-keri/tests/cases/roundtrip.rs @@ -1,55 +1,54 @@ use auths_keri::{CesrV1Codec, export_kel_as_cesr, import_cesr_to_events}; -use auths_verifier::keri::{IcpEvent, IxnEvent, KeriEvent, Prefix, RotEvent, Said, Seal}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -fn make_signed_icp() -> KeriEvent { - KeriEvent::Inception(IcpEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "0".into(), - kt: "1".into(), - k: vec!["DTestKey12345678901234567890123456789012".into()], - nt: "1".into(), - n: vec!["ETestNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], - a: vec![], - x: URL_SAFE_NO_PAD.encode([10u8; 64]), +fn make_signed_icp() -> serde_json::Value { + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "ETestSaid1234567890123456789012345678901", + "i": "ETestPrefix123456789012345678901234567890", + "s": "0", + "kt": "1", + "k": ["DTestKey12345678901234567890123456789012"], + "nt": "1", + "n": ["ETestNext12345678901234567890123456789012"], + "bt": "0", + "b": [], + "a": [], + "x": URL_SAFE_NO_PAD.encode([10u8; 64]) }) } -fn make_signed_rot() -> KeriEvent { - KeriEvent::Rotation(RotEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "1".into(), - p: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - kt: "1".into(), - k: vec!["DNewKey123456789012345678901234567890123".into()], - nt: "1".into(), - n: vec!["ENewNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], - a: vec![], - x: URL_SAFE_NO_PAD.encode([20u8; 64]), +fn make_signed_rot() -> serde_json::Value { + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "rot", + "d": "ETestRotSaid23456789012345678901234567890", + "i": "ETestPrefix123456789012345678901234567890", + "s": "1", + "p": "ETestSaid1234567890123456789012345678901", + "kt": "1", + "k": ["DNewKey123456789012345678901234567890123"], + "nt": "1", + "n": ["ENewNext12345678901234567890123456789012"], + "bt": "0", + "b": [], + "a": [], + "x": URL_SAFE_NO_PAD.encode([20u8; 64]) }) } -fn make_signed_ixn() -> KeriEvent { - KeriEvent::Interaction(IxnEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestIxnSaid23456789012345678901234567890".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "2".into(), - p: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - a: vec![Seal { - d: Said::new_unchecked("ESealDigest234567890123456789012345678901".into()), - seal_type: "device-attestation".into(), - }], - x: URL_SAFE_NO_PAD.encode([30u8; 64]), +fn make_signed_ixn() -> serde_json::Value { + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "ixn", + "d": "ETestIxnSaid23456789012345678901234567890", + "i": "ETestPrefix123456789012345678901234567890", + "s": "2", + "p": "ETestRotSaid23456789012345678901234567890", + "a": [{"d": "ESealDigest234567890123456789012345678901", "type": "device-attestation"}], + "x": URL_SAFE_NO_PAD.encode([30u8; 64]) }) } @@ -80,9 +79,9 @@ fn roundtrip_preserves_event_types() { let stream = export_kel_as_cesr(&codec, &events).unwrap(); let reimported = import_cesr_to_events(&codec, &stream.bytes).unwrap(); - assert!(matches!(reimported[0], KeriEvent::Inception(_))); - assert!(matches!(reimported[1], KeriEvent::Rotation(_))); - assert!(matches!(reimported[2], KeriEvent::Interaction(_))); + assert_eq!(reimported[0].get("t").and_then(|v| v.as_str()), Some("icp")); + assert_eq!(reimported[1].get("t").and_then(|v| v.as_str()), Some("rot")); + assert_eq!(reimported[2].get("t").and_then(|v| v.as_str()), Some("ixn")); } #[test] @@ -92,21 +91,20 @@ fn roundtrip_preserves_keys_and_commitments() { let stream = export_kel_as_cesr(&codec, &events).unwrap(); let reimported = import_cesr_to_events(&codec, &stream.bytes).unwrap(); - let KeriEvent::Inception(original) = &events[0] else { - panic!() - }; - let KeriEvent::Inception(reimported) = &reimported[0] else { - panic!() - }; + let original = &events[0]; + let reimported = &reimported[0]; - assert_eq!(reimported.k, original.k, "keys must survive round-trip"); assert_eq!( - reimported.n, original.n, + reimported["k"], original["k"], + "keys must survive round-trip" + ); + assert_eq!( + reimported["n"], original["n"], "commitments must survive round-trip" ); - assert_eq!(reimported.kt, original.kt); - assert_eq!(reimported.nt, original.nt); - assert_eq!(reimported.s, "0"); + assert_eq!(reimported["kt"], original["kt"]); + assert_eq!(reimported["nt"], original["nt"]); + assert_eq!(reimported["s"], "0"); } #[test] @@ -116,12 +114,12 @@ fn roundtrip_reimported_events_have_x_field() { let stream = export_kel_as_cesr(&codec, &events).unwrap(); let reimported = import_cesr_to_events(&codec, &stream.bytes).unwrap(); - let KeriEvent::Inception(icp) = &reimported[0] else { - panic!() - }; - assert!(!icp.x.is_empty(), "reimported event must have x field"); + let x = reimported[0]["x"] + .as_str() + .expect("x field must be present"); + assert!(!x.is_empty(), "reimported event must have x field"); - let sig_bytes = URL_SAFE_NO_PAD.decode(&icp.x).unwrap(); + let sig_bytes = URL_SAFE_NO_PAD.decode(x).unwrap(); assert_eq!(sig_bytes.len(), 64, "signature must be 64 bytes"); assert_eq!( sig_bytes, @@ -137,11 +135,9 @@ fn roundtrip_preserves_seals() { let stream = export_kel_as_cesr(&codec, &events).unwrap(); let reimported = import_cesr_to_events(&codec, &stream.bytes).unwrap(); - let KeriEvent::Interaction(ixn) = &reimported[0] else { - panic!() - }; - assert_eq!(ixn.a.len(), 1); - assert_eq!(ixn.a[0].seal_type, "device-attestation"); + let a = reimported[0]["a"].as_array().expect("a must be array"); + assert_eq!(a.len(), 1); + assert_eq!(a[0]["type"], "device-attestation"); } #[test] @@ -151,16 +147,11 @@ fn roundtrip_multi_event_preserves_chain_links() { let stream = export_kel_as_cesr(&codec, &events).unwrap(); let reimported = import_cesr_to_events(&codec, &stream.bytes).unwrap(); - let KeriEvent::Rotation(rot) = &reimported[1] else { - panic!() - }; - let KeriEvent::Interaction(ixn) = &reimported[2] else { - panic!() - }; + let rot_p = reimported[1]["p"].as_str().expect("rot must have p field"); + let ixn_p = reimported[2]["p"].as_str().expect("ixn must have p field"); - // Chain links (p fields) should be populated with spec SAIDs, not original SAIDs. - assert!(!rot.p.is_empty(), "rotation must have p field"); - assert!(!ixn.p.is_empty(), "interaction must have p field"); + assert!(!rot_p.is_empty(), "rotation must have p field"); + assert!(!ixn_p.is_empty(), "interaction must have p field"); } #[test] @@ -177,5 +168,5 @@ fn export_single_event_roundtrip() { let stream = export_kel_as_cesr(&codec, &events).unwrap(); let reimported = import_cesr_to_events(&codec, &stream.bytes).unwrap(); assert_eq!(reimported.len(), 1); - assert!(matches!(reimported[0], KeriEvent::Rotation(_))); + assert_eq!(reimported[0].get("t").and_then(|v| v.as_str()), Some("rot")); } diff --git a/crates/auths-keri/tests/cases/stream.rs b/crates/auths-keri/tests/cases/stream.rs index e525d278..07a73343 100644 --- a/crates/auths-keri/tests/cases/stream.rs +++ b/crates/auths-keri/tests/cases/stream.rs @@ -1,55 +1,54 @@ use auths_keri::{CesrV1Codec, assemble_cesr_stream, serialize_for_cesr}; -use auths_verifier::keri::{IcpEvent, IxnEvent, KeriEvent, Prefix, RotEvent, Said, Seal}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -fn make_signed_icp() -> KeriEvent { - KeriEvent::Inception(IcpEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "0".into(), - kt: "1".into(), - k: vec!["DTestKey12345678901234567890123456789012".into()], - nt: "1".into(), - n: vec!["ETestNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], - a: vec![], - x: URL_SAFE_NO_PAD.encode([1u8; 64]), +fn make_signed_icp() -> serde_json::Value { + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "ETestSaid1234567890123456789012345678901", + "i": "ETestPrefix123456789012345678901234567890", + "s": "0", + "kt": "1", + "k": ["DTestKey12345678901234567890123456789012"], + "nt": "1", + "n": ["ETestNext12345678901234567890123456789012"], + "bt": "0", + "b": [], + "a": [], + "x": URL_SAFE_NO_PAD.encode([1u8; 64]) }) } -fn make_signed_rot() -> KeriEvent { - KeriEvent::Rotation(RotEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "1".into(), - p: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - kt: "1".into(), - k: vec!["DNewKey123456789012345678901234567890123".into()], - nt: "1".into(), - n: vec!["ENewNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], - a: vec![], - x: URL_SAFE_NO_PAD.encode([2u8; 64]), +fn make_signed_rot() -> serde_json::Value { + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "rot", + "d": "ETestRotSaid23456789012345678901234567890", + "i": "ETestPrefix123456789012345678901234567890", + "s": "1", + "p": "ETestSaid1234567890123456789012345678901", + "kt": "1", + "k": ["DNewKey123456789012345678901234567890123"], + "nt": "1", + "n": ["ENewNext12345678901234567890123456789012"], + "bt": "0", + "b": [], + "a": [], + "x": URL_SAFE_NO_PAD.encode([2u8; 64]) }) } -fn make_signed_ixn() -> KeriEvent { - KeriEvent::Interaction(IxnEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestIxnSaid23456789012345678901234567890".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "2".into(), - p: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - a: vec![Seal { - d: Said::new_unchecked("ESealDigest234567890123456789012345678901".into()), - seal_type: "device-attestation".into(), - }], - x: URL_SAFE_NO_PAD.encode([3u8; 64]), +fn make_signed_ixn() -> serde_json::Value { + serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "ixn", + "d": "ETestIxnSaid23456789012345678901234567890", + "i": "ETestPrefix123456789012345678901234567890", + "s": "2", + "p": "ETestRotSaid23456789012345678901234567890", + "a": [{"d": "ESealDigest234567890123456789012345678901", "type": "device-attestation"}], + "x": URL_SAFE_NO_PAD.encode([3u8; 64]) }) } @@ -96,7 +95,6 @@ fn stream_byte_count_matches_sum_of_parts() { .map(|e| serialize_for_cesr(&codec, e).unwrap()) .collect(); - // Compute expected size: body + counter code (4 bytes) + signature (88 bytes) per event. let expected: usize = serialized .iter() .map(|s| { @@ -116,19 +114,20 @@ fn stream_byte_count_matches_sum_of_parts() { #[test] fn event_without_signature_omits_attachment() { let codec = CesrV1Codec::new(); - let event = KeriEvent::Inception(IcpEvent { - v: "KERI10JSON000000_".into(), - d: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), - i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "0".into(), - kt: "1".into(), - k: vec!["DTestKey12345678901234567890123456789012".into()], - nt: "1".into(), - n: vec!["ETestNext12345678901234567890123456789012".into()], - bt: "0".into(), - b: vec![], - a: vec![], - x: String::new(), + let event = serde_json::json!({ + "v": "KERI10JSON000000_", + "t": "icp", + "d": "ETestSaid1234567890123456789012345678901", + "i": "ETestPrefix123456789012345678901234567890", + "s": "0", + "kt": "1", + "k": ["DTestKey12345678901234567890123456789012"], + "nt": "1", + "n": ["ETestNext12345678901234567890123456789012"], + "bt": "0", + "b": [], + "a": [], + "x": "" }); let serialized = serialize_for_cesr(&codec, &event).unwrap(); let stream = assemble_cesr_stream(&codec, std::slice::from_ref(&serialized)).unwrap(); diff --git a/crates/auths-radicle/Cargo.toml b/crates/auths-radicle/Cargo.toml index e5c7aa16..409fe534 100644 --- a/crates/auths-radicle/Cargo.toml +++ b/crates/auths-radicle/Cargo.toml @@ -26,6 +26,7 @@ wasm = ["dep:wasm-bindgen", "auths-verifier/wasm"] [dependencies] # WASM-safe: always available +auths-keri.workspace = true auths-verifier = { workspace = true, default-features = false } bs58 = "0.5.1" json-canon = "=0.1.3" diff --git a/crates/auths-radicle/src/identity.rs b/crates/auths-radicle/src/identity.rs index 59959a18..457fdf81 100644 --- a/crates/auths-radicle/src/identity.rs +++ b/crates/auths-radicle/src/identity.rs @@ -157,7 +157,7 @@ impl RadicleIdentityResolver { let mut keys = Vec::with_capacity(key_state.current_keys.len()); for key_str in &key_state.current_keys { - let keri_pk = auths_crypto::KeriPublicKey::parse(key_str).map_err(|e| { + let keri_pk = auths_keri::KeriPublicKey::parse(key_str).map_err(|e| { IdentityError::KelValidationFailed(format!("invalid CESR key: {e}")) })?; let public_key = PublicKey::try_from(keri_pk.into_bytes().as_slice()) @@ -738,7 +738,7 @@ impl DidResolver for RadicleIdentityResolver { .first() .ok_or_else(|| DidResolverError::Resolution("no signing keys in KEL".into()))?; - let keri_pk = auths_crypto::KeriPublicKey::parse(cesr_key) + let keri_pk = auths_keri::KeriPublicKey::parse(cesr_key) .map_err(|e| DidResolverError::Resolution(format!("invalid CESR key: {e}")))?; Ok(ResolvedDid::Keri { diff --git a/crates/auths-radicle/src/verify.rs b/crates/auths-radicle/src/verify.rs index df64bd2d..345e0fa2 100644 --- a/crates/auths-radicle/src/verify.rs +++ b/crates/auths-radicle/src/verify.rs @@ -469,8 +469,8 @@ pub fn meets_threshold( #[allow(clippy::unwrap_used, clippy::disallowed_methods)] mod tests { use super::*; + use auths_keri::{Prefix, Said}; use auths_verifier::IdentityDID; - use auths_verifier::keri::{Prefix, Said}; use auths_verifier::types::CanonicalDid; use auths_verifier::types::DeviceDID as VerifierDeviceDID; use chrono::{DateTime, Utc}; diff --git a/crates/auths-radicle/tests/cases/helpers.rs b/crates/auths-radicle/tests/cases/helpers.rs index 9faf788d..4bf1e1ab 100644 --- a/crates/auths-radicle/tests/cases/helpers.rs +++ b/crates/auths-radicle/tests/cases/helpers.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; use auths_id::keri::KeyState; +use auths_keri::{Prefix, Said}; use auths_radicle::bridge::BridgeError; use auths_radicle::refs::Layout; use auths_radicle::verify::AuthsStorage; use auths_verifier::AttestationBuilder; use auths_verifier::IdentityDID; use auths_verifier::core::{Attestation, Capability}; -use auths_verifier::keri::{Prefix, Said}; use auths_verifier::types::DeviceDID; use radicle_core::{Did, RepoId}; use radicle_crypto::PublicKey; diff --git a/crates/auths-sdk/Cargo.toml b/crates/auths-sdk/Cargo.toml index 38db320a..3e93608f 100644 --- a/crates/auths-sdk/Cargo.toml +++ b/crates/auths-sdk/Cargo.toml @@ -11,6 +11,7 @@ keywords = ["sdk", "identity", "did", "cryptography", "attestation"] categories = ["cryptography", "authentication"] [dependencies] +auths-keri.workspace = true async-trait = "0.1" auths-core = { workspace = true, public = true } auths-id = { workspace = true, public = true } diff --git a/crates/auths-sdk/src/domains/identity/registration.rs b/crates/auths-sdk/src/domains/identity/registration.rs index f3d1b873..feb7ab42 100644 --- a/crates/auths-sdk/src/domains/identity/registration.rs +++ b/crates/auths-sdk/src/domains/identity/registration.rs @@ -6,8 +6,8 @@ use auths_core::ports::network::{NetworkError, RegistryClient}; use auths_id::ports::registry::RegistryBackend; use auths_id::storage::attestation::AttestationSource; use auths_id::storage::identity::IdentityStorage; +use auths_keri::Prefix; use auths_verifier::IdentityDID; -use auths_verifier::keri::Prefix; use crate::domains::identity::error::RegistrationError; use crate::domains::identity::types::RegistrationOutcome; @@ -57,7 +57,7 @@ pub async fn register_identity( .load_identity() .map_err(RegistrationError::IdentityLoadError)?; - let prefix = Prefix::from_did(&identity.controller_did).map_err(|_| { + let prefix = Prefix::new(identity.controller_did.prefix().to_string()).map_err(|_| { RegistrationError::InvalidDidFormat { did: identity.controller_did.to_string(), } diff --git a/crates/auths-sdk/src/domains/identity/rotation.rs b/crates/auths-sdk/src/domains/identity/rotation.rs index 7e406ca8..e8365e84 100644 --- a/crates/auths-sdk/src/domains/identity/rotation.rs +++ b/crates/auths-sdk/src/domains/identity/rotation.rs @@ -10,7 +10,6 @@ use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use zeroize::Zeroizing; -use auths_core::crypto::said::{compute_next_commitment, compute_said, verify_commitment}; use auths_core::crypto::signer::{decrypt_keypair, encrypt_keypair, load_seed_and_pubkey}; use auths_core::ports::clock::ClockProvider; use auths_core::storage::keychain::{ @@ -24,6 +23,7 @@ use auths_id::keri::{ }; use auths_id::ports::registry::RegistryBackend; use auths_id::witness_config::WitnessConfig; +use auths_keri::{compute_next_commitment, compute_said, verify_commitment}; use crate::context::AuthsContext; use crate::domains::identity::error::RotationError; diff --git a/crates/auths-sdk/src/workflows/rotation.rs b/crates/auths-sdk/src/workflows/rotation.rs index 71993391..6b98484a 100644 --- a/crates/auths-sdk/src/workflows/rotation.rs +++ b/crates/auths-sdk/src/workflows/rotation.rs @@ -10,7 +10,6 @@ use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use zeroize::Zeroizing; -use auths_core::crypto::said::{compute_next_commitment, compute_said, verify_commitment}; use auths_core::crypto::signer::{decrypt_keypair, encrypt_keypair, load_seed_and_pubkey}; use auths_core::ports::clock::ClockProvider; use auths_core::storage::keychain::{ @@ -24,6 +23,7 @@ use auths_id::keri::{ }; use auths_id::ports::registry::RegistryBackend; use auths_id::witness_config::WitnessConfig; +use auths_keri::{compute_next_commitment, compute_said, verify_commitment}; use crate::context::AuthsContext; use crate::error::RotationError; diff --git a/crates/auths-storage/Cargo.toml b/crates/auths-storage/Cargo.toml index 78649f7a..83fa9751 100644 --- a/crates/auths-storage/Cargo.toml +++ b/crates/auths-storage/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["storage", "git", "sqlite", "identity", "adapter"] categories = ["database"] [dependencies] +auths-keri.workspace = true auths-id = { workspace = true } auths-core = { workspace = true } auths-crypto = { workspace = true } diff --git a/crates/auths-storage/src/git/adapter.rs b/crates/auths-storage/src/git/adapter.rs index fdc1a0ab..051a1d76 100644 --- a/crates/auths-storage/src/git/adapter.rs +++ b/crates/auths-storage/src/git/adapter.rs @@ -53,7 +53,7 @@ use git2::{Oid, Repository, Signature, Tree}; use auths_id::keri::event::Event; use auths_id::keri::state::KeyState; use auths_id::keri::validate::{ValidationError, verify_event_crypto, verify_event_said}; -use auths_verifier::keri::Prefix; +use auths_keri::Prefix; use super::paths; use super::vfs::{OsVfs, Vfs}; @@ -646,7 +646,7 @@ impl GitRegistryBackend { for (prefix_str, state) in &state_overlay { if let Some(index) = &self.index { let indexed = auths_index::IndexedIdentity { - prefix: auths_verifier::keri::Prefix::new_unchecked(prefix_str.clone()), + prefix: auths_keri::Prefix::new_unchecked(prefix_str.clone()), current_keys: state.current_keys.clone(), sequence: state.sequence, tip_said: state.last_event_said.clone(), @@ -1393,7 +1393,7 @@ impl RegistryBackend for GitRegistryBackend { if let Some(index) = &self.index { #[allow(clippy::disallowed_methods)] // INVARIANT: org is a validated KERI prefix from registry storage - let org_prefix = auths_verifier::keri::Prefix::new_unchecked(org.to_string()); + let org_prefix = auths_keri::Prefix::new_unchecked(org.to_string()); #[allow(clippy::disallowed_methods)] // INVARIANT: member.issuer is a validated CanonicalDid let issuer_did = IdentityDID::new_unchecked(member.issuer.as_str()); @@ -1830,7 +1830,7 @@ pub fn rebuild_org_members_from_registry( if let Ok(att) = &entry.attestation { #[allow(clippy::disallowed_methods)] // INVARIANT: org_prefix is a validated KERI prefix from visit_orgs - let prefix = auths_verifier::keri::Prefix::new_unchecked(org_prefix.clone()); + let prefix = auths_keri::Prefix::new_unchecked(org_prefix.clone()); #[allow(clippy::disallowed_methods)] // INVARIANT: att.issuer is a validated CanonicalDid from deserialized attestation let issuer_did = IdentityDID::new_unchecked(att.issuer.as_str()); @@ -2095,12 +2095,12 @@ impl StorageDriver for GitRegistryBackend { #[allow(clippy::disallowed_methods)] mod tests { use super::*; - use auths_core::crypto::said::compute_next_commitment; - use auths_id::keri::KERI_VERSION; use auths_id::keri::event::{IcpEvent, IxnEvent, KeriSequence, RotEvent}; use auths_id::keri::seal::Seal; use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{compute_event_said, finalize_icp_event, serialize_for_signing}; + use auths_keri::KERI_VERSION; + use auths_keri::compute_next_commitment; use auths_verifier::AttestationBuilder; use auths_verifier::core::{Ed25519PublicKey, Role}; use base64::Engine; @@ -3664,12 +3664,12 @@ mod tests { #[allow(clippy::unwrap_used, clippy::expect_used)] mod index_consistency_tests { use super::*; - use auths_core::crypto::said::compute_next_commitment; - use auths_id::keri::KERI_VERSION; use auths_id::keri::event::{IcpEvent, KeriSequence}; use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing}; use auths_id::storage::registry::org_member::MemberFilter; + use auths_keri::KERI_VERSION; + use auths_keri::compute_next_commitment; use auths_verifier::core::{Ed25519PublicKey, Ed25519Signature, ResourceId}; use auths_verifier::types::CanonicalDid; use base64::Engine; @@ -3919,11 +3919,11 @@ mod tenant_isolation_tests { use ring::signature::{Ed25519KeyPair, KeyPair}; use tempfile::TempDir; - use auths_core::crypto::said::compute_next_commitment; - use auths_id::keri::KERI_VERSION; use auths_id::keri::event::{IcpEvent, KeriSequence}; use auths_id::keri::types::{Prefix, Said}; use auths_id::keri::validate::{finalize_icp_event, serialize_for_signing}; + use auths_keri::KERI_VERSION; + use auths_keri::compute_next_commitment; use super::*; use auths_id::storage::registry::backend::TenantIdError; diff --git a/crates/auths-storage/src/git/identity_adapter.rs b/crates/auths-storage/src/git/identity_adapter.rs index 72c07778..387e7c12 100644 --- a/crates/auths-storage/src/git/identity_adapter.rs +++ b/crates/auths-storage/src/git/identity_adapter.rs @@ -107,12 +107,12 @@ impl RegistryIdentityStorage { &self, metadata: Option, witness_config: Option<&auths_id::witness_config::WitnessConfig>, - ) -> Result<(String, auths_id::keri::InceptionResult), InitError> { - use auths_core::crypto::said::compute_next_commitment; + ) -> Result<(String, auths_id::keri::inception::InceptionResult), InitError> { use auths_id::keri::{ Event, IcpEvent, InceptionResult, KERI_VERSION, KeriSequence, Prefix, Said, finalize_icp_event, serialize_for_signing, }; + use auths_keri::compute_next_commitment; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; @@ -206,7 +206,7 @@ impl RegistryIdentityStorage { /// Store metadata for an identity. fn store_metadata( &self, - prefix: &auths_verifier::keri::Prefix, + prefix: &auths_keri::Prefix, metadata: Option, ) -> Result<(), StorageError> { let repo = Repository::open(&self.repo_path)?; @@ -254,7 +254,7 @@ impl RegistryIdentityStorage { /// Load metadata for an identity. fn load_metadata( &self, - prefix: &auths_verifier::keri::Prefix, + prefix: &auths_keri::Prefix, ) -> Result, StorageError> { let repo = Repository::open(&self.repo_path)?; @@ -311,7 +311,7 @@ impl IdentityStorage for RegistryIdentityStorage { controller_did: &str, metadata: Option, ) -> Result<(), StorageError> { - use auths_verifier::keri::Prefix; + use auths_keri::Prefix; // Extract prefix from controller_did (did:keri:{prefix}) let prefix_str = controller_did.strip_prefix("did:keri:").ok_or_else(|| { @@ -326,7 +326,7 @@ impl IdentityStorage for RegistryIdentityStorage { } fn load_identity(&self) -> Result { - use auths_verifier::keri::Prefix; + use auths_keri::Prefix; // Find the first (and typically only) identity in the registry let prefix_str = self diff --git a/crates/auths-storage/src/postgres/adapter.rs b/crates/auths-storage/src/postgres/adapter.rs index 02417db6..9107bbc4 100644 --- a/crates/auths-storage/src/postgres/adapter.rs +++ b/crates/auths-storage/src/postgres/adapter.rs @@ -11,8 +11,8 @@ use auths_id::keri::state::KeyState; use auths_id::ports::registry::{ OrgMemberEntry, RegistryBackend, RegistryError, RegistryMetadata, TipInfo, }; +use auths_keri::Prefix; use auths_verifier::core::Attestation; -use auths_verifier::keri::Prefix; use auths_verifier::types::DeviceDID; /// PostgreSQL-backed registry storage (stub). diff --git a/crates/auths-verifier/Cargo.toml b/crates/auths-verifier/Cargo.toml index fd00f377..8030a95a 100644 --- a/crates/auths-verifier/Cargo.toml +++ b/crates/auths-verifier/Cargo.toml @@ -16,8 +16,8 @@ crate-type = ["rlib", "cdylib"] [dependencies] auths-crypto.workspace = true +auths-keri.workspace = true base64.workspace = true -blake3 = "1.5" bs58 = "0.5.1" chrono = { version = "0.4", features = ["serde"] } hex = { version = "0.4.3", features = ["serde"] } @@ -29,7 +29,6 @@ schemars = { workspace = true, optional = true, features = ["chrono"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.10" -subtle.workspace = true thiserror.workspace = true # Optional dependencies diff --git a/crates/auths-verifier/src/ffi.rs b/crates/auths-verifier/src/ffi.rs index 454cc974..857202a2 100644 --- a/crates/auths-verifier/src/ffi.rs +++ b/crates/auths-verifier/src/ffi.rs @@ -2,8 +2,9 @@ use crate::core::{Attestation, MAX_ATTESTATION_JSON_SIZE, MAX_JSON_BATCH_SIZE}; use crate::error::AttestationError; use crate::types::DeviceDID; use crate::verifier::Verifier; -use crate::witness::{WitnessReceipt, WitnessVerifyConfig}; +use crate::witness::WitnessVerifyConfig; use auths_crypto::ED25519_PUBLIC_KEY_LEN; +use auths_keri::witness::Receipt; use log::error; use std::os::raw::c_int; use std::panic; @@ -87,8 +88,8 @@ type WitnessKeys = Vec<(String, Vec)>; fn parse_witness_inputs( receipts_json: &[u8], witness_keys_json: &[u8], -) -> Result<(Vec, WitnessKeys), c_int> { - let receipts: Vec = serde_json::from_slice(receipts_json).map_err(|e| { +) -> Result<(Vec, WitnessKeys), c_int> { + let receipts: Vec = serde_json::from_slice(receipts_json).map_err(|e| { error!("FFI: receipts JSON parse error: {}", e); ERR_VERIFY_WITNESS_PARSE })?; @@ -232,7 +233,7 @@ pub unsafe extern "C" fn ffi_verify_attestation_json( /// # Arguments /// * `chain_json_ptr` / `chain_json_len` - JSON array of attestations /// * `root_pk_ptr` / `root_pk_len` - 32-byte Ed25519 root public key -/// * `receipts_json_ptr` / `receipts_json_len` - JSON array of WitnessReceipt objects +/// * `receipts_json_ptr` / `receipts_json_len` - JSON array of Receipt objects /// * `witness_keys_json_ptr` / `witness_keys_json_len` - JSON array of `{"did": "...", "pk_hex": "..."}` /// * `threshold` - Minimum number of valid witness receipts required /// * `result_ptr` / `result_len` - Output buffer for JSON VerificationReport diff --git a/crates/auths-verifier/src/keri.rs b/crates/auths-verifier/src/keri.rs deleted file mode 100644 index fea8b1df..00000000 --- a/crates/auths-verifier/src/keri.rs +++ /dev/null @@ -1,1329 +0,0 @@ -//! Stateless KERI KEL verification. -//! -//! This module provides verification of KERI event logs without requiring -//! Git or filesystem access. Events are provided as input, making it -//! suitable for WASM and FFI consumers. - -use std::borrow::Borrow; -use std::fmt; - -use auths_crypto::CryptoProvider; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use serde::ser::SerializeMap; -use serde::{Deserialize, Serialize, Serializer}; -use subtle::ConstantTimeEq; - -// ── KERI Identifier Newtypes ──────────────────────────────────────────────── - -/// Error when constructing KERI newtypes with invalid values. -#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] -#[error("Invalid KERI {type_name}: {reason}")] -pub struct KeriTypeError { - /// Which KERI type failed validation. - pub type_name: &'static str, - /// Why validation failed. - pub reason: String, -} - -/// Shared validation for KERI self-addressing identifiers. -/// -/// Both `Prefix` and `Said` must start with 'E' (Blake3-256 derivation code). -fn validate_keri_derivation_code(s: &str, type_label: &'static str) -> Result<(), KeriTypeError> { - if s.is_empty() { - return Err(KeriTypeError { - type_name: type_label, - reason: "must not be empty".into(), - }); - } - if !s.starts_with('E') { - return Err(KeriTypeError { - type_name: type_label, - reason: format!( - "must start with 'E' (Blake3 derivation code), got '{}'", - &s[..s.len().min(10)] - ), - }); - } - Ok(()) -} - -/// Strongly-typed KERI identifier prefix (e.g., `"ETest123..."`). -/// -/// A prefix is the self-addressing identifier derived from the inception event's -/// Blake3 hash. Always starts with 'E' (Blake3-256 derivation code). -/// -/// Args: -/// * Inner `String` should start with `'E'` (enforced by `new()`, not by serde). -/// -/// Usage: -/// ```ignore -/// let prefix = Prefix::new("ETest123abc".to_string())?; -/// assert_eq!(prefix.as_str(), "ETest123abc"); -/// ``` -#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[repr(transparent)] -pub struct Prefix(String); - -impl Prefix { - /// Validates and wraps a KERI prefix string. - pub fn new(s: String) -> Result { - validate_keri_derivation_code(&s, "Prefix")?; - Ok(Self(s)) - } - - /// Wraps a prefix string without validation (for trusted internal paths). - pub fn new_unchecked(s: String) -> Self { - Self(s) - } - - /// Extracts the KERI prefix from an `IdentityDID`. - /// - /// Args: - /// * `did`: A validated `IdentityDID` (e.g., `did:keri:ETest123`). - /// - /// Usage: - /// ```rust - /// # use auths_verifier::{IdentityDID, keri::Prefix}; - /// let did = IdentityDID::parse("did:keri:ETest123").unwrap(); - /// let prefix = Prefix::from_did(&did).unwrap(); - /// assert_eq!(prefix.as_str(), "ETest123"); - /// ``` - pub fn from_did(did: &crate::types::IdentityDID) -> Result { - let raw = did.prefix(); - validate_keri_derivation_code(raw, "Prefix")?; - Ok(Self(raw.to_string())) - } - - /// Returns the inner string slice. - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Consumes self and returns the inner String. - pub fn into_inner(self) -> String { - self.0 - } - - /// Returns true if the inner string is empty (placeholder during event construction). - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl fmt::Display for Prefix { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl AsRef for Prefix { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl Borrow for Prefix { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl From for String { - fn from(p: Prefix) -> String { - p.0 - } -} - -impl PartialEq for Prefix { - fn eq(&self, other: &str) -> bool { - self.0 == other - } -} - -impl PartialEq<&str> for Prefix { - fn eq(&self, other: &&str) -> bool { - self.0 == *other - } -} - -impl PartialEq for str { - fn eq(&self, other: &Prefix) -> bool { - self == other.0 - } -} - -impl PartialEq for &str { - fn eq(&self, other: &Prefix) -> bool { - *self == other.0 - } -} - -/// KERI Self-Addressing Identifier (SAID). -/// -/// A Blake3 hash that uniquely identifies a KERI event. Creates the -/// hash chain: each event's `p` (previous) field is the prior event's SAID. -/// -/// Structurally identical to `Prefix` (both start with 'E') but semantically -/// distinct — a prefix identifies an *identity*, a SAID identifies an *event*. -/// -/// Args: -/// * Inner `String` should start with `'E'` (enforced by `new()`, not by serde). -/// -/// Usage: -/// ```ignore -/// let said = Said::new("ESAID123".to_string())?; -/// assert_eq!(said.as_str(), "ESAID123"); -/// ``` -#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[repr(transparent)] -pub struct Said(String); - -impl Said { - /// Validates and wraps a KERI SAID string. - pub fn new(s: String) -> Result { - validate_keri_derivation_code(&s, "Said")?; - Ok(Self(s)) - } - - /// Wraps a SAID string without validation (for `compute_said()` output and storage loads). - pub fn new_unchecked(s: String) -> Self { - Self(s) - } - - /// Returns the inner string slice. - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Consumes self and returns the inner String. - pub fn into_inner(self) -> String { - self.0 - } - - /// Returns true if the inner string is empty (placeholder during event construction). - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl fmt::Display for Said { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl AsRef for Said { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl Borrow for Said { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl From for String { - fn from(s: Said) -> String { - s.0 - } -} - -impl PartialEq for Said { - fn eq(&self, other: &str) -> bool { - self.0 == other - } -} - -impl PartialEq<&str> for Said { - fn eq(&self, other: &&str) -> bool { - self.0 == *other - } -} - -impl PartialEq for str { - fn eq(&self, other: &Said) -> bool { - self == other.0 - } -} - -impl PartialEq for &str { - fn eq(&self, other: &Said) -> bool { - *self == other.0 - } -} - -// ── KERI Verification Errors ──────────────────────────────────────────────── - -/// Errors specific to KERI KEL verification. -#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] -pub enum KeriVerifyError { - /// The computed SAID does not match the SAID stored in the event. - #[error("Invalid SAID: expected {expected}, got {actual}")] - InvalidSaid { - /// The SAID computed from the event content. - expected: Said, - /// The SAID found in the event field. - actual: Said, - }, - /// The `p` field of an event does not match the SAID of the preceding event. - #[error("Broken chain at seq {sequence}: references {referenced}, previous was {actual}")] - BrokenChain { - /// Sequence number of the event with the broken link. - sequence: u64, - /// The SAID referenced by the `p` field. - referenced: Said, - /// The SAID of the actual preceding event. - actual: Said, - }, - /// Event sequence number does not follow the expected monotonic order. - #[error("Invalid sequence: expected {expected}, got {actual}")] - InvalidSequence { - /// The expected sequence number. - expected: u64, - /// The sequence number found in the event. - actual: u64, - }, - /// The rotation key does not satisfy the pre-rotation commitment from the prior event. - #[error("Pre-rotation commitment mismatch at sequence {sequence}")] - CommitmentMismatch { - /// Sequence number of the rotation event that failed commitment verification. - sequence: u64, - }, - /// Ed25519 signature verification failed. - #[error("Signature verification failed at sequence {sequence}")] - SignatureFailed { - /// Sequence number of the event whose signature failed. - sequence: u64, - }, - /// The KEL's first event is not an inception (`icp`) event. - #[error("First event must be inception")] - NotInception, - /// The KEL contains no events. - #[error("Empty KEL")] - EmptyKel, - /// More than one inception event was found in the KEL. - #[error("Multiple inception events")] - MultipleInceptions, - /// JSON serialization or deserialization failed. - #[error("Serialization error: {0}")] - Serialization(String), - /// The key encoding prefix is unsupported or malformed. - #[error("Invalid key encoding: {0}")] - InvalidKey(String), - /// The sequence number string cannot be parsed as a `u64`. - #[error("Malformed sequence number: {raw:?}")] - MalformedSequence { - /// The raw sequence string that could not be parsed. - raw: String, - }, -} - -use auths_crypto::KeriPublicKey; - -/// KERI event types for verification. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(tag = "t")] -pub enum KeriEvent { - /// Inception event (`icp`) — creates the identity and establishes the first key. - #[serde(rename = "icp")] - Inception(IcpEvent), - /// Rotation event (`rot`) — rotates to the pre-committed key. - #[serde(rename = "rot")] - Rotation(RotEvent), - /// Interaction event (`ixn`) — anchors data without rotating keys. - #[serde(rename = "ixn")] - Interaction(IxnEvent), -} - -impl Serialize for KeriEvent { - fn serialize(&self, serializer: S) -> Result { - match self { - KeriEvent::Inception(e) => e.serialize(serializer), - KeriEvent::Rotation(e) => e.serialize(serializer), - KeriEvent::Interaction(e) => e.serialize(serializer), - } - } -} - -impl KeriEvent { - /// Get the SAID of this event. - pub fn said(&self) -> &Said { - match self { - KeriEvent::Inception(e) => &e.d, - KeriEvent::Rotation(e) => &e.d, - KeriEvent::Interaction(e) => &e.d, - } - } - - /// Get the signature of this event. - pub fn signature(&self) -> &str { - match self { - KeriEvent::Inception(e) => &e.x, - KeriEvent::Rotation(e) => &e.x, - KeriEvent::Interaction(e) => &e.x, - } - } - - /// Get the sequence number of this event. - pub fn sequence(&self) -> Result { - let s = match self { - KeriEvent::Inception(e) => &e.s, - KeriEvent::Rotation(e) => &e.s, - KeriEvent::Interaction(e) => &e.s, - }; - s.parse::() - .map_err(|_| KeriVerifyError::MalformedSequence { raw: s.clone() }) - } -} - -/// Inception event. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct IcpEvent { - /// KERI version string (e.g. `"KERI10JSON"`). - pub v: String, - /// Self-Addressing Identifier (SAID) of this event. - #[serde(default)] - pub d: Said, - /// KERI prefix — same as `d` for inception. - pub i: Prefix, - /// Sequence number (always `"0"` for inception). - pub s: String, - /// Signing key threshold. - #[serde(default)] - pub kt: String, - /// Current signing keys (base64url-encoded with derivation prefix). - pub k: Vec, - /// Next-key commitment threshold. - #[serde(default)] - pub nt: String, - /// Next-key commitments (Blake3 hashes of the pre-rotation public keys). - pub n: Vec, - /// Witness threshold. - #[serde(default)] - pub bt: String, - /// Witness list (DIDs or URLs of witnesses). - #[serde(default)] - pub b: Vec, - /// Anchored seals (attached data digests). - #[serde(default)] - pub a: Vec, - /// Ed25519 signature over the canonical event body. - #[serde(default)] - pub x: String, -} - -/// Spec field order: v, t, d, i, s, kt, k, nt, n, bt, b, a, x -impl Serialize for IcpEvent { - fn serialize(&self, serializer: S) -> Result { - let field_count = 12 + (!self.d.is_empty() as usize) + (!self.x.is_empty() as usize); - let mut map = serializer.serialize_map(Some(field_count))?; - map.serialize_entry("v", &self.v)?; - map.serialize_entry("t", "icp")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } - map.serialize_entry("i", &self.i)?; - map.serialize_entry("s", &self.s)?; - map.serialize_entry("kt", &self.kt)?; - map.serialize_entry("k", &self.k)?; - map.serialize_entry("nt", &self.nt)?; - map.serialize_entry("n", &self.n)?; - map.serialize_entry("bt", &self.bt)?; - map.serialize_entry("b", &self.b)?; - map.serialize_entry("a", &self.a)?; - if !self.x.is_empty() { - map.serialize_entry("x", &self.x)?; - } - map.end() - } -} - -/// Rotation event. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct RotEvent { - /// KERI version string. - pub v: String, - /// SAID of this event. - #[serde(default)] - pub d: Said, - /// KERI prefix of the identity being rotated. - pub i: Prefix, - /// Sequence number. - pub s: String, - /// SAID of the prior event (chain link). - pub p: Said, - /// Signing key threshold. - #[serde(default)] - pub kt: String, - /// Current signing keys after rotation. - pub k: Vec, - /// Next-key commitment threshold. - #[serde(default)] - pub nt: String, - /// Next-key commitments for the subsequent rotation. - pub n: Vec, - /// Witness threshold. - #[serde(default)] - pub bt: String, - /// Witness list. - #[serde(default)] - pub b: Vec, - /// Anchored seals. - #[serde(default)] - pub a: Vec, - /// Ed25519 signature over the canonical event body. - #[serde(default)] - pub x: String, -} - -/// Spec field order: v, t, d, i, s, p, kt, k, nt, n, bt, b, a, x -impl Serialize for RotEvent { - fn serialize(&self, serializer: S) -> Result { - let field_count = 13 + (!self.d.is_empty() as usize) + (!self.x.is_empty() as usize); - let mut map = serializer.serialize_map(Some(field_count))?; - map.serialize_entry("v", &self.v)?; - map.serialize_entry("t", "rot")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } - map.serialize_entry("i", &self.i)?; - map.serialize_entry("s", &self.s)?; - map.serialize_entry("p", &self.p)?; - map.serialize_entry("kt", &self.kt)?; - map.serialize_entry("k", &self.k)?; - map.serialize_entry("nt", &self.nt)?; - map.serialize_entry("n", &self.n)?; - map.serialize_entry("bt", &self.bt)?; - map.serialize_entry("b", &self.b)?; - map.serialize_entry("a", &self.a)?; - if !self.x.is_empty() { - map.serialize_entry("x", &self.x)?; - } - map.end() - } -} - -/// Interaction event. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct IxnEvent { - /// KERI version string. - pub v: String, - /// SAID of this event. - #[serde(default)] - pub d: Said, - /// KERI prefix of the identity. - pub i: Prefix, - /// Sequence number. - pub s: String, - /// SAID of the prior event (chain link). - pub p: Said, - /// Anchored seals (e.g. attestation digests). - pub a: Vec, - /// Ed25519 signature over the canonical event body. - #[serde(default)] - pub x: String, -} - -/// Spec field order: v, t, d, i, s, p, a, x -impl Serialize for IxnEvent { - fn serialize(&self, serializer: S) -> Result { - let field_count = 7 + (!self.d.is_empty() as usize) + (!self.x.is_empty() as usize); - let mut map = serializer.serialize_map(Some(field_count))?; - map.serialize_entry("v", &self.v)?; - map.serialize_entry("t", "ixn")?; - if !self.d.is_empty() { - map.serialize_entry("d", &self.d)?; - } - map.serialize_entry("i", &self.i)?; - map.serialize_entry("s", &self.s)?; - map.serialize_entry("p", &self.p)?; - map.serialize_entry("a", &self.a)?; - if !self.x.is_empty() { - map.serialize_entry("x", &self.x)?; - } - map.end() - } -} - -/// A seal anchors external data in a KERI event. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct Seal { - /// Digest (SAID) of the anchored data. - pub d: Said, - /// Semantic type label (e.g. `"device-attestation"`). - #[serde(rename = "type")] - pub seal_type: String, -} - -/// Result of KEL verification. -#[derive(Debug, Clone, Serialize)] -pub struct KeriKeyState { - /// The KERI prefix - pub prefix: Prefix, - - /// The current public key (raw bytes) - #[serde(skip)] - pub current_key: Vec, - - /// The current public key (encoded, e.g. "D..." base64url) - pub current_key_encoded: String, - - /// The next-key commitment (if any) - pub next_commitment: Option, - - /// The current sequence number - pub sequence: u64, - - /// Whether the identity is abandoned (no next commitment) - pub is_abandoned: bool, - - /// The SAID of the last processed event - pub last_event_said: Said, -} - -/// Verify a KERI event log and return the resulting key state. -/// -/// This is a stateless function that validates the cryptographic integrity -/// of a KEL without requiring filesystem access. Verifies SAID integrity, -/// chain linkage, sequence ordering, pre-rotation commitments, and Ed25519 -/// signatures on every event. -/// -/// # Arguments -/// * `events` - Ordered list of events (inception first) -/// * `provider` - Crypto provider for Ed25519 signature verification -/// -/// # Returns -/// * `Ok(KeriKeyState)` - The current key state after replaying events -/// * `Err(KeriVerifyError)` - If validation fails -pub async fn verify_kel( - events: &[KeriEvent], - provider: &dyn CryptoProvider, -) -> Result { - if events.is_empty() { - return Err(KeriVerifyError::EmptyKel); - } - - let KeriEvent::Inception(icp) = &events[0] else { - return Err(KeriVerifyError::NotInception); - }; - - verify_event_said(&events[0])?; - - let icp_key = icp - .k - .first() - .ok_or(KeriVerifyError::SignatureFailed { sequence: 0 })?; - verify_event_signature(&events[0], icp_key, provider).await?; - - let current_key = decode_key(icp_key)?; - let current_key_encoded = icp_key.clone(); - - let mut state = KeriKeyState { - prefix: icp.i.clone(), - current_key, - current_key_encoded, - next_commitment: icp.n.first().cloned(), - sequence: 0, - is_abandoned: icp.n.is_empty(), - last_event_said: icp.d.clone(), - }; - - for (idx, event) in events.iter().enumerate().skip(1) { - let expected_seq = idx as u64; - - verify_event_said(event)?; - - match event { - KeriEvent::Rotation(rot) => { - let actual_seq = event.sequence()?; - if actual_seq != expected_seq { - return Err(KeriVerifyError::InvalidSequence { - expected: expected_seq, - actual: actual_seq, - }); - } - - if rot.p != state.last_event_said { - return Err(KeriVerifyError::BrokenChain { - sequence: actual_seq, - referenced: rot.p.clone(), - actual: state.last_event_said.clone(), - }); - } - - if !rot.k.is_empty() { - verify_event_signature(event, &rot.k[0], provider).await?; - - let new_key_bytes = decode_key(&rot.k[0])?; - - if let Some(commitment) = &state.next_commitment - && !verify_commitment(&new_key_bytes, commitment) - { - return Err(KeriVerifyError::CommitmentMismatch { - sequence: actual_seq, - }); - } - - state.current_key = new_key_bytes; - state.current_key_encoded = rot.k[0].clone(); - } - - state.next_commitment = rot.n.first().cloned(); - state.is_abandoned = rot.n.is_empty(); - state.sequence = actual_seq; - state.last_event_said = rot.d.clone(); - } - KeriEvent::Interaction(ixn) => { - let actual_seq = event.sequence()?; - if actual_seq != expected_seq { - return Err(KeriVerifyError::InvalidSequence { - expected: expected_seq, - actual: actual_seq, - }); - } - - if ixn.p != state.last_event_said { - return Err(KeriVerifyError::BrokenChain { - sequence: actual_seq, - referenced: ixn.p.clone(), - actual: state.last_event_said.clone(), - }); - } - - verify_event_signature(event, &state.current_key_encoded, provider).await?; - - state.sequence = actual_seq; - state.last_event_said = ixn.d.clone(); - } - KeriEvent::Inception(_) => { - return Err(KeriVerifyError::MultipleInceptions); - } - } - } - - Ok(state) -} - -/// Serialize event for signing/SAID computation (clears d, x, and for ICP also i). -/// -/// This produces the canonical form over which both SAID and signatures are computed. -fn serialize_for_signing(event: &KeriEvent) -> Result, KeriVerifyError> { - match event { - KeriEvent::Inception(e) => { - let mut copy = e.clone(); - copy.d = Said::default(); - copy.i = Prefix::default(); - copy.x = String::new(); - serde_json::to_vec(&KeriEvent::Inception(copy)) - } - KeriEvent::Rotation(e) => { - let mut copy = e.clone(); - copy.d = Said::default(); - copy.x = String::new(); - serde_json::to_vec(&KeriEvent::Rotation(copy)) - } - KeriEvent::Interaction(e) => { - let mut copy = e.clone(); - copy.d = Said::default(); - copy.x = String::new(); - serde_json::to_vec(&KeriEvent::Interaction(copy)) - } - } - .map_err(|e| KeriVerifyError::Serialization(e.to_string())) -} - -/// Verify an event's SAID matches its content. -fn verify_event_said(event: &KeriEvent) -> Result<(), KeriVerifyError> { - let json = serialize_for_signing(event)?; - let computed = compute_said(&json); - let said = event.said(); - - if computed != *said { - return Err(KeriVerifyError::InvalidSaid { - expected: computed, - actual: said.clone(), - }); - } - - Ok(()) -} - -/// Verify an event's Ed25519 signature using the specified key. -async fn verify_event_signature( - event: &KeriEvent, - signing_key: &str, - provider: &dyn CryptoProvider, -) -> Result<(), KeriVerifyError> { - let sequence = event.sequence()?; - - let sig_str = event.signature(); - if sig_str.is_empty() { - return Err(KeriVerifyError::SignatureFailed { sequence }); - } - let sig_bytes = URL_SAFE_NO_PAD - .decode(sig_str) - .map_err(|_| KeriVerifyError::SignatureFailed { sequence })?; - - let key_bytes = - decode_key(signing_key).map_err(|_| KeriVerifyError::SignatureFailed { sequence })?; - - let canonical = serialize_for_signing(event)?; - - provider - .verify_ed25519(&key_bytes, &canonical, &sig_bytes) - .await - .map_err(|_| KeriVerifyError::SignatureFailed { sequence })?; - - Ok(()) -} - -/// Compute a KERI Self-Addressing Identifier (SAID) using Blake3. -// SYNC: must match auths-core/src/crypto/said.rs — tested by said_cross_validation -pub fn compute_said(data: &[u8]) -> Said { - let hash = blake3::hash(data); - Said::new_unchecked(format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes()))) -} - -/// Compute next-key commitment. -// SYNC: must match auths-core/src/crypto/said.rs — tested by said_cross_validation -fn compute_commitment(public_key: &[u8]) -> String { - let hash = blake3::hash(public_key); - format!("E{}", URL_SAFE_NO_PAD.encode(hash.as_bytes())) -} - -// Defense-in-depth: both values are derived from public data, but constant-time -// comparison prevents timing side-channels on commitment verification. -fn verify_commitment(public_key: &[u8], commitment: &str) -> bool { - let computed = compute_commitment(public_key); - computed.as_bytes().ct_eq(commitment.as_bytes()).into() -} - -/// Decode a KERI key (D-prefixed Base64url for Ed25519). -fn decode_key(key_str: &str) -> Result, KeriVerifyError> { - KeriPublicKey::parse(key_str) - .map(|k| k.into_bytes().to_vec()) - .map_err(|e| KeriVerifyError::InvalidKey(e.to_string())) -} - -/// Check if a seal with given digest exists in any IXN event. -/// -/// Returns the sequence number of the IXN event if found. -pub fn find_seal_in_kel(events: &[KeriEvent], digest: &str) -> Option { - for event in events { - if let KeriEvent::Interaction(ixn) = event { - for seal in &ixn.a { - if seal.d.as_str() == digest { - return ixn.s.parse::().ok(); - } - } - } - } - None -} - -/// Parse events from JSON. -pub fn parse_kel_json(json: &str) -> Result, KeriVerifyError> { - serde_json::from_str(json).map_err(|e| KeriVerifyError::Serialization(e.to_string())) -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use auths_crypto::RingCryptoProvider; - use ring::rand::SystemRandom; - use ring::signature::{Ed25519KeyPair, KeyPair}; - - fn provider() -> RingCryptoProvider { - RingCryptoProvider - } - - fn finalize_icp(mut icp: IcpEvent) -> IcpEvent { - icp.d = Said::default(); - icp.i = Prefix::default(); - icp.x = String::new(); - let json = serde_json::to_vec(&KeriEvent::Inception(icp.clone())).unwrap(); - let said = compute_said(&json); - icp.i = Prefix::new_unchecked(said.as_str().to_string()); - icp.d = said; - icp - } - - // ── Signed helpers ── - - fn make_signed_icp(keypair: &Ed25519KeyPair, next_commitment: &str) -> IcpEvent { - let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(keypair.public_key().as_ref())); - - let mut icp = IcpEvent { - v: "KERI10JSON".into(), - d: Said::default(), - i: Prefix::default(), - s: "0".into(), - kt: "1".into(), - k: vec![key_encoded], - nt: "1".into(), - n: vec![next_commitment.to_string()], - bt: "0".into(), - b: vec![], - a: vec![], - x: String::new(), - }; - - // Finalize SAID - icp = finalize_icp(icp); - - // Sign - let canonical = serialize_for_signing(&KeriEvent::Inception(icp.clone())).unwrap(); - let sig = keypair.sign(&canonical); - icp.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - icp - } - - fn make_signed_rot( - prefix: &str, - prev_said: &str, - seq: u64, - new_keypair: &Ed25519KeyPair, - next_commitment: &str, - ) -> RotEvent { - let key_encoded = format!( - "D{}", - URL_SAFE_NO_PAD.encode(new_keypair.public_key().as_ref()) - ); - - let mut rot = RotEvent { - v: "KERI10JSON".into(), - d: Said::default(), - i: Prefix::new_unchecked(prefix.to_string()), - s: seq.to_string(), - p: Said::new_unchecked(prev_said.to_string()), - kt: "1".into(), - k: vec![key_encoded], - nt: "1".into(), - n: vec![next_commitment.to_string()], - bt: "0".into(), - b: vec![], - a: vec![], - x: String::new(), - }; - - // Compute SAID - let json = serialize_for_signing(&KeriEvent::Rotation(rot.clone())).unwrap(); - rot.d = compute_said(&json); - - // Sign with the NEW key - let canonical = serialize_for_signing(&KeriEvent::Rotation(rot.clone())).unwrap(); - let sig = new_keypair.sign(&canonical); - rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - rot - } - - fn make_signed_ixn( - prefix: &str, - prev_said: &str, - seq: u64, - keypair: &Ed25519KeyPair, - seals: Vec, - ) -> IxnEvent { - let mut ixn = IxnEvent { - v: "KERI10JSON".into(), - d: Said::default(), - i: Prefix::new_unchecked(prefix.to_string()), - s: seq.to_string(), - p: Said::new_unchecked(prev_said.to_string()), - a: seals, - x: String::new(), - }; - - // Compute SAID - let json = serialize_for_signing(&KeriEvent::Interaction(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); - - // Sign - let canonical = serialize_for_signing(&KeriEvent::Interaction(ixn.clone())).unwrap(); - let sig = keypair.sign(&canonical); - ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - ixn - } - - fn generate_keypair() -> (Ed25519KeyPair, Vec) { - let rng = SystemRandom::new(); - let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); - let pkcs8_bytes = pkcs8.as_ref().to_vec(); - let keypair = Ed25519KeyPair::from_pkcs8(&pkcs8_bytes).unwrap(); - (keypair, pkcs8_bytes) - } - - // ── Structural tests (existing, updated for x field) ── - - #[tokio::test] - async fn rejects_empty_kel() { - let result = verify_kel(&[], &provider()).await; - assert!(matches!(result, Err(KeriVerifyError::EmptyKel))); - } - - #[tokio::test] - async fn rejects_non_inception_first() { - let ixn = KeriEvent::Interaction(IxnEvent { - v: "KERI10JSON".into(), - d: Said::new_unchecked("EIXN".into()), - i: Prefix::new_unchecked("EPrefix".into()), - s: "0".into(), - p: Said::new_unchecked("EPrev".into()), - a: vec![], - x: String::new(), - }); - - let result = verify_kel(&[ixn], &provider()).await; - assert!(matches!(result, Err(KeriVerifyError::NotInception))); - } - - #[test] - fn find_seal_locates_attestation_sync() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let next_commitment = compute_commitment(kp2.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next_commitment); - - // Create signed IXN with seal - let ixn = make_signed_ixn( - icp.i.as_str(), - icp.d.as_str(), - 1, - &kp1, - vec![Seal { - d: Said::new_unchecked("EAttDigest".into()), - seal_type: "device-attestation".into(), - }], - ); - - let events = vec![KeriEvent::Inception(icp), KeriEvent::Interaction(ixn)]; - - let found = find_seal_in_kel(&events, "EAttDigest"); - assert_eq!(found, Some(1)); - - let not_found = find_seal_in_kel(&events, "ENotExist"); - assert_eq!(not_found, None); - } - - #[test] - fn decode_key_works() { - let key_bytes = [42u8; 32]; - let encoded = format!("D{}", URL_SAFE_NO_PAD.encode(key_bytes)); - - let decoded = decode_key(&encoded).unwrap(); - assert_eq!(decoded, key_bytes); - } - - #[test] - fn decode_key_rejects_unknown_code() { - let result = decode_key("Xsomething"); - assert!(matches!(result, Err(KeriVerifyError::InvalidKey(_)))); - } - - // ── Signed verification tests ── - - #[tokio::test] - async fn verify_signed_inception() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let next_commitment = compute_commitment(kp2.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next_commitment); - let events = vec![KeriEvent::Inception(icp.clone())]; - - let state = verify_kel(&events, &provider()).await.unwrap(); - assert_eq!(state.prefix, icp.i); - assert_eq!(state.current_key, kp1.public_key().as_ref()); - assert_eq!(state.sequence, 0); - assert!(!state.is_abandoned); - } - - #[tokio::test] - async fn verify_icp_rot_ixn_signed() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let (kp3, _) = generate_keypair(); - - let next1_commitment = compute_commitment(kp2.public_key().as_ref()); - let next2_commitment = compute_commitment(kp3.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next1_commitment); - let rot = make_signed_rot(icp.i.as_str(), icp.d.as_str(), 1, &kp2, &next2_commitment); - let ixn = make_signed_ixn( - icp.i.as_str(), - rot.d.as_str(), - 2, - &kp2, - vec![Seal { - d: Said::new_unchecked("EAttest".into()), - seal_type: "device-attestation".into(), - }], - ); - - let events = vec![ - KeriEvent::Inception(icp.clone()), - KeriEvent::Rotation(rot), - KeriEvent::Interaction(ixn), - ]; - - let state = verify_kel(&events, &provider()).await.unwrap(); - assert_eq!(state.prefix, icp.i); - assert_eq!(state.current_key, kp2.public_key().as_ref()); - assert_eq!(state.sequence, 2); - } - - #[tokio::test] - async fn rejects_forged_signature() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let next_commitment = compute_commitment(kp2.public_key().as_ref()); - - let mut icp = make_signed_icp(&kp1, &next_commitment); - icp.x = URL_SAFE_NO_PAD.encode([0u8; 64]); - - let events = vec![KeriEvent::Inception(icp)]; - let result = verify_kel(&events, &provider()).await; - assert!(matches!( - result, - Err(KeriVerifyError::SignatureFailed { sequence: 0 }) - )); - } - - #[tokio::test] - async fn rejects_missing_signature() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let next_commitment = compute_commitment(kp2.public_key().as_ref()); - - let mut icp = make_signed_icp(&kp1, &next_commitment); - icp.x = String::new(); - - let events = vec![KeriEvent::Inception(icp)]; - let result = verify_kel(&events, &provider()).await; - assert!(matches!( - result, - Err(KeriVerifyError::SignatureFailed { sequence: 0 }) - )); - } - - #[tokio::test] - async fn rejects_wrong_key_signature() { - let (kp1, _) = generate_keypair(); - let (kp_wrong, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let next_commitment = compute_commitment(kp2.public_key().as_ref()); - - let key_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(kp1.public_key().as_ref())); - let mut icp = IcpEvent { - v: "KERI10JSON".into(), - d: Said::default(), - i: Prefix::default(), - s: "0".into(), - kt: "1".into(), - k: vec![key_encoded], - nt: "1".into(), - n: vec![next_commitment], - bt: "0".into(), - b: vec![], - a: vec![], - x: String::new(), - }; - icp = finalize_icp(icp); - - let canonical = serialize_for_signing(&KeriEvent::Inception(icp.clone())).unwrap(); - let sig = kp_wrong.sign(&canonical); - icp.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let events = vec![KeriEvent::Inception(icp)]; - let result = verify_kel(&events, &provider()).await; - assert!(matches!( - result, - Err(KeriVerifyError::SignatureFailed { sequence: 0 }) - )); - } - - #[tokio::test] - async fn rejects_rot_signed_with_old_key() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let (kp3, _) = generate_keypair(); - - let next1_commitment = compute_commitment(kp2.public_key().as_ref()); - let next2_commitment = compute_commitment(kp3.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next1_commitment); - - let key2_encoded = format!("D{}", URL_SAFE_NO_PAD.encode(kp2.public_key().as_ref())); - let mut rot = RotEvent { - v: "KERI10JSON".into(), - d: Said::default(), - i: icp.i.clone(), - s: "1".into(), - p: icp.d.clone(), - kt: "1".into(), - k: vec![key2_encoded], - nt: "1".into(), - n: vec![next2_commitment], - bt: "0".into(), - b: vec![], - a: vec![], - x: String::new(), - }; - - let json = serialize_for_signing(&KeriEvent::Rotation(rot.clone())).unwrap(); - rot.d = compute_said(&json); - - let canonical = serialize_for_signing(&KeriEvent::Rotation(rot.clone())).unwrap(); - let sig = kp1.sign(&canonical); - rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let events = vec![KeriEvent::Inception(icp), KeriEvent::Rotation(rot)]; - let result = verify_kel(&events, &provider()).await; - assert!(matches!( - result, - Err(KeriVerifyError::SignatureFailed { sequence: 1 }) - )); - } - - #[tokio::test] - async fn rotation_updates_signing_key_for_ixn() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let (kp3, _) = generate_keypair(); - - let next1_commitment = compute_commitment(kp2.public_key().as_ref()); - let next2_commitment = compute_commitment(kp3.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next1_commitment); - let rot = make_signed_rot(icp.i.as_str(), icp.d.as_str(), 1, &kp2, &next2_commitment); - let ixn = make_signed_ixn(icp.i.as_str(), rot.d.as_str(), 2, &kp1, vec![]); - - let events = vec![ - KeriEvent::Inception(icp), - KeriEvent::Rotation(rot), - KeriEvent::Interaction(ixn), - ]; - let result = verify_kel(&events, &provider()).await; - assert!(matches!( - result, - Err(KeriVerifyError::SignatureFailed { sequence: 2 }) - )); - } - - #[tokio::test] - async fn rejects_wrong_commitment() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let (kp_wrong, _) = generate_keypair(); - let (kp3, _) = generate_keypair(); - - let next1_commitment = compute_commitment(kp2.public_key().as_ref()); - let next2_commitment = compute_commitment(kp3.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next1_commitment); - let rot = make_signed_rot( - icp.i.as_str(), - icp.d.as_str(), - 1, - &kp_wrong, - &next2_commitment, - ); - - let events = vec![KeriEvent::Inception(icp), KeriEvent::Rotation(rot)]; - let result = verify_kel(&events, &provider()).await; - assert!(matches!( - result, - Err(KeriVerifyError::CommitmentMismatch { sequence: 1 }) - )); - } - - #[tokio::test] - async fn rejects_broken_chain() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let next_commitment = compute_commitment(kp2.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next_commitment); - - let mut ixn = IxnEvent { - v: "KERI10JSON".into(), - d: Said::default(), - i: icp.i.clone(), - s: "1".into(), - p: Said::new_unchecked("EWrongPrevious".into()), - a: vec![], - x: String::new(), - }; - - let json = serialize_for_signing(&KeriEvent::Interaction(ixn.clone())).unwrap(); - ixn.d = compute_said(&json); - - let canonical = serialize_for_signing(&KeriEvent::Interaction(ixn.clone())).unwrap(); - let sig = kp1.sign(&canonical); - ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref()); - - let events = vec![KeriEvent::Inception(icp), KeriEvent::Interaction(ixn)]; - let result = verify_kel(&events, &provider()).await; - assert!(matches!(result, Err(KeriVerifyError::BrokenChain { .. }))); - } - - #[tokio::test] - async fn verify_kel_with_rotation() { - let (kp1, _) = generate_keypair(); - let (kp2, _) = generate_keypair(); - let (kp3, _) = generate_keypair(); - - let next1_commitment = compute_commitment(kp2.public_key().as_ref()); - let next2_commitment = compute_commitment(kp3.public_key().as_ref()); - - let icp = make_signed_icp(&kp1, &next1_commitment); - let rot = make_signed_rot(icp.i.as_str(), icp.d.as_str(), 1, &kp2, &next2_commitment); - - let events = vec![KeriEvent::Inception(icp), KeriEvent::Rotation(rot)]; - - let state = verify_kel(&events, &provider()).await.unwrap(); - assert_eq!(state.sequence, 1); - assert_eq!(state.current_key, kp2.public_key().as_ref()); - } - - #[test] - fn rejects_malformed_sequence_number() { - // An event with a non-numeric sequence must be rejected, not coerced to 0 - let icp = IcpEvent { - v: "KERI10JSON".into(), - d: Said::default(), - i: Prefix::default(), - s: "not_a_number".to_string(), - kt: "1".to_string(), - k: vec!["DKey".to_string()], - nt: "1".to_string(), - n: vec!["ENext".to_string()], - bt: "0".to_string(), - b: vec![], - a: vec![], - x: String::new(), - }; - - let event = KeriEvent::Inception(icp); - let result = event.sequence(); - assert!( - matches!(result, Err(KeriVerifyError::MalformedSequence { .. })), - "Expected MalformedSequence error, got: {:?}", - result - ); - } -} diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index 7466d02e..3badafb6 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -57,7 +57,6 @@ pub mod error; /// C-compatible FFI bindings for attestation and chain verification. #[cfg(feature = "ffi")] pub mod ffi; -pub mod keri; pub mod ssh_sig; pub mod types; pub mod verifier; @@ -113,13 +112,13 @@ pub use verify::{ }; // Re-export witness types -pub use witness::{WitnessQuorum, WitnessReceipt, WitnessReceiptResult, WitnessVerifyConfig}; +pub use witness::{WitnessQuorum, WitnessReceiptResult, WitnessVerifyConfig}; -// Re-export KERI verification types (key parsing lives in auths-crypto) -pub use keri::{ - IcpEvent as KeriIcpEvent, IxnEvent as KeriIxnEvent, KeriEvent, KeriKeyState, KeriTypeError, - KeriVerifyError, Prefix, RotEvent as KeriRotEvent, Said, Seal as KeriSeal, compute_said, - find_seal_in_kel, parse_kel_json, verify_kel, +// Re-export KERI types directly from auths-keri +pub use auths_keri::{ + Event as KeriEvent, IcpEvent as KeriIcpEvent, IxnEvent as KeriIxnEvent, KeriTypeError, Prefix, + RotEvent as KeriRotEvent, Said, Seal as KeriSeal, ValidationError, compute_said, + find_seal_in_kel, parse_kel_json, }; // Re-export commit verification types diff --git a/crates/auths-verifier/src/types.rs b/crates/auths-verifier/src/types.rs index 742d7ab5..25a79eaa 100644 --- a/crates/auths-verifier/src/types.rs +++ b/crates/auths-verifier/src/types.rs @@ -743,7 +743,7 @@ impl FromStr for AssuranceLevel { #[cfg(test)] mod tests { use super::*; - use crate::keri::Said; + use auths_keri::Said; #[test] fn report_without_witness_quorum_deserializes() { diff --git a/crates/auths-verifier/src/verify.rs b/crates/auths-verifier/src/verify.rs index 65854481..c7b72d3d 100644 --- a/crates/auths-verifier/src/verify.rs +++ b/crates/auths-verifier/src/verify.rs @@ -8,6 +8,7 @@ use crate::types::{ChainLink, VerificationReport, VerificationStatus}; #[cfg(feature = "native")] use crate::witness::WitnessVerifyConfig; use auths_crypto::{CryptoProvider, ED25519_PUBLIC_KEY_LEN}; +use auths_keri::{Event, compute_said, find_seal_in_kel}; use chrono::{DateTime, Duration, Utc}; use log::debug; use serde::Serialize; @@ -208,14 +209,14 @@ pub struct DeviceLinkVerification { pub error: Option, /// The KERI key state after KEL replay (present on success). #[serde(skip_serializing_if = "Option::is_none")] - pub key_state: Option, + pub key_state: Option, /// Sequence number of the IXN event anchoring the attestation seal (if found). #[serde(skip_serializing_if = "Option::is_none")] pub seal_sequence: Option, } impl DeviceLinkVerification { - fn success(key_state: crate::keri::KeriKeyState, seal_sequence: Option) -> Self { + fn success(key_state: auths_keri::KeyState, seal_sequence: Option) -> Self { Self { valid: true, error: None, @@ -252,13 +253,13 @@ impl DeviceLinkVerification { /// if result.valid { /* ... */ } /// ``` pub async fn verify_device_link( - events: &[crate::keri::KeriEvent], + events: &[Event], attestation: &Attestation, device_did: &str, now: DateTime, provider: &dyn CryptoProvider, ) -> DeviceLinkVerification { - let key_state = match crate::keri::verify_kel(events, provider).await { + let key_state = match auths_keri::validate_kel(events) { Ok(ks) => ks, Err(e) => return DeviceLinkVerification::failure(format!("KEL verification failed: {e}")), }; @@ -270,15 +271,25 @@ pub async fn verify_device_link( )); } - if let Err(e) = - verify_with_keys_at(attestation, &key_state.current_key, now, true, provider).await - { + let current_pk = match key_state.current_keys.first() { + Some(encoded) => { + match auths_keri::KeriPublicKey::parse(encoded).map(|k| k.into_bytes().to_vec()) { + Ok(bytes) => bytes, + Err(e) => { + return DeviceLinkVerification::failure(format!("Invalid current key: {e}")); + } + } + } + None => return DeviceLinkVerification::failure("KEL has no current keys"), + }; + + if let Err(e) = verify_with_keys_at(attestation, ¤t_pk, now, true, provider).await { return DeviceLinkVerification::failure(format!("Attestation verification failed: {e}")); } let seal_sequence = compute_attestation_seal_digest(attestation) .ok() - .and_then(|digest| crate::keri::find_seal_in_kel(events, digest.as_str())); + .and_then(|digest| find_seal_in_kel(events, digest.as_str())); DeviceLinkVerification::success(key_state, seal_sequence) } @@ -291,7 +302,7 @@ pub fn compute_attestation_seal_digest( attestation: &Attestation, ) -> Result { let canonical = canonicalize_attestation_data(&attestation.canonical_data())?; - Ok(crate::keri::compute_said(&canonical).to_string()) + Ok(compute_said(&canonical).to_string()) } // --------------------------------------------------------------------------- @@ -522,11 +533,11 @@ mod tests { use super::*; use crate::clock::ClockProvider; use crate::core::{Capability, Ed25519PublicKey, Ed25519Signature, ResourceId, Role}; - use crate::keri::Said; use crate::types::{CanonicalDid, DeviceDID}; use crate::verifier::Verifier; use auths_crypto::RingCryptoProvider; use auths_crypto::testing::create_test_keypair; + use auths_keri::Said; use chrono::{DateTime, Duration, TimeZone, Utc}; use ring::signature::{Ed25519KeyPair, KeyPair}; use std::sync::Arc; @@ -1759,8 +1770,8 @@ mod tests { witness_did: &str, event_said: &str, seq: u64, - ) -> crate::witness::WitnessReceipt { - let mut receipt = crate::witness::WitnessReceipt { + ) -> auths_keri::witness::Receipt { + let mut receipt = auths_keri::witness::Receipt { v: "KERI10JSON000000_".into(), t: "rct".into(), d: Said::new_unchecked(format!("EReceipt_{}", seq)), diff --git a/crates/auths-verifier/src/wasm.rs b/crates/auths-verifier/src/wasm.rs index b014302b..d129af7b 100644 --- a/crates/auths-verifier/src/wasm.rs +++ b/crates/auths-verifier/src/wasm.rs @@ -4,11 +4,11 @@ use crate::core::{ MAX_PUBLIC_KEY_HEX_LEN, MAX_SIGNATURE_HEX_LEN, }; use crate::error::{AttestationError, AuthsErrorInfo}; -use crate::keri; use crate::types::VerificationReport; use crate::verify; -use crate::witness::{WitnessReceipt, WitnessVerifyConfig}; +use crate::witness::WitnessVerifyConfig; use auths_crypto::{CryptoProvider, ED25519_PUBLIC_KEY_LEN, WebCryptoProvider}; +use auths_keri::witness::Receipt; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -305,7 +305,7 @@ async fn verify_chain_with_witnesses_internal( AttestationError::SerializationError(format!("Failed to parse attestations JSON: {}", e)) })?; - let receipts: Vec = serde_json::from_str(receipts_json).map_err(|e| { + let receipts: Vec = serde_json::from_str(receipts_json).map_err(|e| { AttestationError::SerializationError(format!("Failed to parse receipts JSON: {}", e)) })?; @@ -366,10 +366,10 @@ async fn verify_chain_with_witnesses_internal( /// /// Usage: /// ```ignore -/// let key_state_json = verifyKelJson("[{\"v\":\"KERI10JSON\",\"t\":\"icp\",...}]").await?; +/// let key_state_json = validateKelJson("[{\"v\":\"KERI10JSON\",\"t\":\"icp\",...}]").await?; /// ``` -#[wasm_bindgen(js_name = verifyKelJson)] -pub async fn wasm_verify_kel_json(kel_json: &str) -> Result { +#[wasm_bindgen(js_name = validateKelJson)] +pub async fn wasm_validate_kel_json(kel_json: &str) -> Result { console_log!("WASM: Verifying KEL..."); if kel_json.len() > MAX_JSON_BATCH_SIZE { @@ -380,11 +380,10 @@ pub async fn wasm_verify_kel_json(kel_json: &str) -> Result { ))); } - let events = keri::parse_kel_json(kel_json) + let events = auths_keri::parse_kel_json(kel_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse KEL JSON: {}", e)))?; - let key_state = keri::verify_kel(&events, &provider()) - .await + let key_state = auths_keri::validate_kel(&events) .map_err(|e| JsValue::from_str(&format!("KEL verification failed: {}", e)))?; console_log!( @@ -435,7 +434,7 @@ pub async fn wasm_verify_device_link( ))); } - let events = keri::parse_kel_json(kel_json) + let events = auths_keri::parse_kel_json(kel_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse KEL JSON: {}", e)))?; let attestation: Attestation = serde_json::from_str(attestation_json) diff --git a/crates/auths-verifier/src/witness.rs b/crates/auths-verifier/src/witness.rs index 4448dda1..dc25d4ab 100644 --- a/crates/auths-verifier/src/witness.rs +++ b/crates/auths-verifier/src/witness.rs @@ -1,76 +1,28 @@ //! Witness receipt verification for the auths-verifier crate. //! -//! This module provides pure verification logic for witness receipts. -//! It defines wire-compatible types that match the `auths_core::witness::Receipt` -//! JSON format, enabling `auths-verifier` to verify witness receipts without -//! depending on `auths-core`. +//! This module provides pure verification logic for witness receipts, +//! re-using `auths_keri::witness::Receipt` as the canonical receipt type. //! //! # Usage //! //! ```rust,ignore -//! use auths_verifier::witness::{WitnessReceipt, WitnessVerifyConfig, verify_witness_receipts}; +//! use auths_verifier::witness::{Receipt, WitnessVerifyConfig, verify_witness_receipts}; //! //! let config = WitnessVerifyConfig { //! receipts: &receipts, //! witness_keys: &[("did:key:z6Mk...".into(), pk_bytes.to_vec())], //! threshold: 2, //! }; -//! let quorum = verify_witness_receipts(&config); +//! let quorum = verify_witness_receipts(&config).await; //! assert!(quorum.verified >= quorum.required); //! ``` +pub use auths_keri::witness::Receipt; + use auths_crypto::CryptoProvider; +use auths_keri::Said; use serde::{Deserialize, Serialize}; -use crate::keri::Said; - -/// Wire-compatible with `auths_core::witness::Receipt`. -/// Same JSON shape: `{ v, t, d, i, s, a, sig }`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WitnessReceipt { - /// Version string (e.g., "KERI10JSON000000_") - pub v: String, - /// Type identifier ("rct" for receipt) - pub t: String, - /// Receipt SAID - pub d: Said, - /// Witness identifier (DID) - pub i: String, - /// Event sequence number - pub s: u64, - /// Event SAID being receipted - pub a: Said, - /// Ed25519 signature over the canonical receipt payload (excluding sig) - #[serde(with = "hex")] - pub sig: Vec, -} - -impl WitnessReceipt { - /// Get the canonical JSON for signature verification (without the sig field). - pub fn signing_payload(&self) -> Result, serde_json::Error> { - let payload = WitnessReceiptSigningPayload { - v: &self.v, - t: &self.t, - d: self.d.as_str(), - i: &self.i, - s: self.s, - a: self.a.as_str(), - }; - serde_json::to_vec(&payload) - } -} - -/// Internal type for signing payload (excludes sig). -#[derive(Serialize)] -struct WitnessReceiptSigningPayload<'a> { - v: &'a str, - t: &'a str, - d: &'a str, - i: &'a str, - s: u64, - a: &'a str, -} - /// Result of witness quorum verification. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct WitnessQuorum { @@ -96,7 +48,7 @@ pub struct WitnessReceiptResult { /// Configuration for witness receipt verification. pub struct WitnessVerifyConfig<'a> { /// The receipts to verify - pub receipts: &'a [WitnessReceipt], + pub receipts: &'a [Receipt], /// Known witness keys: (witness_did, ed25519_public_key_bytes) pub witness_keys: &'a [(String, Vec)], /// Minimum number of valid receipts required @@ -108,7 +60,7 @@ pub struct WitnessVerifyConfig<'a> { /// Returns `true` if the signature over the canonical payload (excluding `sig`) /// is valid for the given public key. pub async fn verify_receipt_signature( - receipt: &WitnessReceipt, + receipt: &Receipt, pk: &[u8], provider: &dyn CryptoProvider, ) -> bool { @@ -181,8 +133,8 @@ mod tests { witness_did: &str, event_said: &str, seq: u64, - ) -> WitnessReceipt { - let mut receipt = WitnessReceipt { + ) -> Receipt { + let mut receipt = Receipt { v: "KERI10JSON000000_".into(), t: "rct".into(), d: Said::new_unchecked(format!("EReceipt_{}", seq)), @@ -198,7 +150,7 @@ mod tests { #[test] fn receipt_signing_payload_excludes_sig() { - let receipt = WitnessReceipt { + let receipt = Receipt { v: "KERI10JSON000000_".into(), t: "rct".into(), d: Said::new_unchecked("EReceipt123".into()), @@ -317,7 +269,7 @@ mod tests { sig_hex ); - let receipt: WitnessReceipt = serde_json::from_str(&json).unwrap(); + let receipt: Receipt = serde_json::from_str(&json).unwrap(); assert_eq!(receipt.v, "KERI10JSON000000_"); assert_eq!(receipt.t, "rct"); assert_eq!(receipt.d, "EReceipt123"); @@ -327,7 +279,7 @@ mod tests { assert_eq!(receipt.sig.len(), 64); let json_out = serde_json::to_string(&receipt).unwrap(); - let parsed: WitnessReceipt = serde_json::from_str(&json_out).unwrap(); + let parsed: Receipt = serde_json::from_str(&json_out).unwrap(); assert_eq!(receipt, parsed); } } diff --git a/crates/auths-verifier/tests/cases/kel_verification.rs b/crates/auths-verifier/tests/cases/kel_verification.rs index d9a732b9..33972a9f 100644 --- a/crates/auths-verifier/tests/cases/kel_verification.rs +++ b/crates/auths-verifier/tests/cases/kel_verification.rs @@ -1,66 +1,18 @@ +use auths_crypto::RingCryptoProvider; use auths_verifier::{ - AttestationBuilder, DeviceLinkVerification, KeriKeyState, KeriVerifyError, Prefix, Said, - parse_kel_json, verify_device_link, verify_kel, + AttestationBuilder, DeviceLinkVerification, ValidationError, parse_kel_json, verify_device_link, }; -use auths_crypto::RingCryptoProvider; - fn provider() -> RingCryptoProvider { RingCryptoProvider } -#[test] -fn keri_key_state_serializes_without_raw_key_bytes() { - let state = KeriKeyState { - prefix: Prefix::new_unchecked("ETestPrefix123".to_string()), - current_key: vec![1, 2, 3, 4], - current_key_encoded: "DTestKey456".to_string(), - next_commitment: Some("ENextCommitment789".to_string()), - sequence: 3, - is_abandoned: false, - last_event_said: Said::new_unchecked("ELastSaid000".to_string()), - }; - - let json = serde_json::to_string(&state).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed["prefix"], "ETestPrefix123"); - assert_eq!(parsed["current_key_encoded"], "DTestKey456"); - assert_eq!(parsed["next_commitment"], "ENextCommitment789"); - assert_eq!(parsed["sequence"], 3); - assert_eq!(parsed["is_abandoned"], false); - assert_eq!(parsed["last_event_said"], "ELastSaid000"); - assert!( - parsed.get("current_key").is_none(), - "raw key bytes should be skipped in serialization" - ); -} - -#[test] -fn keri_key_state_serializes_with_null_next_commitment_when_abandoned() { - let state = KeriKeyState { - prefix: Prefix::new_unchecked("ETestPrefix".to_string()), - current_key: vec![], - current_key_encoded: "DKey".to_string(), - next_commitment: None, - sequence: 0, - is_abandoned: true, - last_event_said: Said::new_unchecked("ESaid".to_string()), - }; - - let json = serde_json::to_string(&state).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - - assert!(parsed["next_commitment"].is_null()); - assert_eq!(parsed["is_abandoned"], true); -} - #[test] fn parse_kel_json_rejects_invalid_json() { let result = parse_kel_json("not valid json"); assert!(result.is_err()); match result.unwrap_err() { - KeriVerifyError::Serialization(_) => {} + ValidationError::Serialization(_) => {} other => panic!("expected Serialization error, got: {:?}", other), } } @@ -71,12 +23,12 @@ fn parse_kel_json_returns_empty_vec_for_empty_array() { assert!(events.is_empty()); } -#[tokio::test] -async fn verify_kel_rejects_empty_events() { - let result = verify_kel(&[], &provider()).await; +#[test] +fn validate_kel_rejects_empty_events() { + let result = auths_keri::validate_kel(&[]); assert!(result.is_err()); match result.unwrap_err() { - KeriVerifyError::EmptyKel => {} + ValidationError::EmptyKel => {} other => panic!("expected EmptyKel error, got: {:?}", other), } } @@ -112,32 +64,6 @@ async fn verify_device_link_fails_on_empty_kel() { assert!(result.key_state.is_none()); } -#[test] -fn device_link_verification_success_serializes_correctly() { - let result = DeviceLinkVerification { - valid: true, - error: None, - key_state: Some(KeriKeyState { - prefix: Prefix::new_unchecked("EPrefix".to_string()), - current_key: vec![], - current_key_encoded: "DKey".to_string(), - next_commitment: None, - sequence: 1, - is_abandoned: false, - last_event_said: Said::new_unchecked("ESaid".to_string()), - }), - seal_sequence: Some(2), - }; - - let json = serde_json::to_string(&result).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed["valid"], true); - assert!(parsed.get("error").is_none()); - assert_eq!(parsed["key_state"]["sequence"], 1); - assert_eq!(parsed["seal_sequence"], 2); -} - #[test] fn device_link_verification_failure_serializes_correctly() { let result = DeviceLinkVerification { diff --git a/crates/auths-verifier/tests/cases/newtypes.rs b/crates/auths-verifier/tests/cases/newtypes.rs index 410f2a0d..bca56b4e 100644 --- a/crates/auths-verifier/tests/cases/newtypes.rs +++ b/crates/auths-verifier/tests/cases/newtypes.rs @@ -1,5 +1,5 @@ use auths_verifier::{ - CommitOid, CommitOidError, IdentityDID, PolicyId, PublicKeyHex, PublicKeyHexError, keri::Prefix, + CommitOid, CommitOidError, IdentityDID, PolicyId, Prefix, PublicKeyHex, PublicKeyHexError, }; // ============================================================================= @@ -100,14 +100,14 @@ fn commit_oid_normalizes_to_lowercase() { #[test] fn prefix_from_did_extracts_keri_prefix() { let did = IdentityDID::parse("did:keri:ETest123abc").unwrap(); - let prefix = Prefix::from_did(&did).unwrap(); + let prefix = Prefix::new(did.prefix().to_string()).unwrap(); assert_eq!(prefix.as_str(), "ETest123abc"); } #[test] fn prefix_from_did_roundtrips_with_identity_did() { let did = IdentityDID::parse("did:keri:EMyPrefix456").unwrap(); - let prefix = Prefix::from_did(&did).unwrap(); + let prefix = Prefix::new(did.prefix().to_string()).unwrap(); let reconstructed = IdentityDID::from_prefix(prefix.as_str()).unwrap(); assert_eq!(did, reconstructed); } diff --git a/crates/auths-verifier/tests/cases/serialization_pinning.rs b/crates/auths-verifier/tests/cases/serialization_pinning.rs index cbdbc102..bbbe289a 100644 --- a/crates/auths-verifier/tests/cases/serialization_pinning.rs +++ b/crates/auths-verifier/tests/cases/serialization_pinning.rs @@ -1,11 +1,13 @@ -use auths_verifier::keri::{IcpEvent, IxnEvent, KeriEvent, Prefix, RotEvent, Said, Seal}; +use auths_keri::{ + Event as KeriEvent, IcpEvent, IxnEvent, KeriSequence, Prefix, RotEvent, Said, Seal, +}; fn make_test_icp() -> IcpEvent { IcpEvent { v: "KERI10JSON000000_".into(), d: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "0".into(), + s: KeriSequence::new(0), kt: "1".into(), k: vec!["DTestKey12345678901234567890123456789012".into()], nt: "1".into(), @@ -22,7 +24,7 @@ fn make_test_rot() -> RotEvent { v: "KERI10JSON000000_".into(), d: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "1".into(), + s: KeriSequence::new(1), p: Said::new_unchecked("ETestSaid1234567890123456789012345678901".into()), kt: "1".into(), k: vec!["DNewKey123456789012345678901234567890123".into()], @@ -40,12 +42,11 @@ fn make_test_ixn() -> IxnEvent { v: "KERI10JSON000000_".into(), d: Said::new_unchecked("ETestIxnSaid23456789012345678901234567890".into()), i: Prefix::new_unchecked("ETestPrefix123456789012345678901234567890".into()), - s: "2".into(), + s: KeriSequence::new(2), p: Said::new_unchecked("ETestRotSaid23456789012345678901234567890".into()), - a: vec![Seal { - d: Said::new_unchecked("ESealDigest234567890123456789012345678901".into()), - seal_type: "device-attestation".into(), - }], + a: vec![Seal::device_attestation( + "ESealDigest234567890123456789012345678901", + )], x: "".into(), } } @@ -76,23 +77,23 @@ fn assert_key_order(json: &str, expected_keys: &[&str]) { #[test] fn icp_field_order_is_pinned() { let icp = make_test_icp(); - let json = serde_json::to_string(&KeriEvent::Inception(icp)).unwrap(); + let json = serde_json::to_string(&KeriEvent::Icp(icp)).unwrap(); + // `a` is omitted when empty (canonical auths-keri format) assert_key_order( &json, - &[ - "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "a", - ], + &["v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b"], ); } #[test] fn rot_field_order_is_pinned() { let rot = make_test_rot(); - let json = serde_json::to_string(&KeriEvent::Rotation(rot)).unwrap(); + let json = serde_json::to_string(&KeriEvent::Rot(rot)).unwrap(); + // `a` is omitted when empty (canonical auths-keri format) assert_key_order( &json, &[ - "v", "t", "d", "i", "s", "p", "kt", "k", "nt", "n", "bt", "b", "a", + "v", "t", "d", "i", "s", "p", "kt", "k", "nt", "n", "bt", "b", ], ); } @@ -100,7 +101,7 @@ fn rot_field_order_is_pinned() { #[test] fn ixn_field_order_is_pinned() { let ixn = make_test_ixn(); - let json = serde_json::to_string(&KeriEvent::Interaction(ixn)).unwrap(); + let json = serde_json::to_string(&KeriEvent::Ixn(ixn)).unwrap(); assert_key_order(&json, &["v", "t", "d", "i", "s", "p", "a"]); } @@ -108,11 +109,11 @@ fn ixn_field_order_is_pinned() { fn icp_with_x_includes_x_last() { let mut icp = make_test_icp(); icp.x = "test_signature".into(); - let json = serde_json::to_string(&KeriEvent::Inception(icp)).unwrap(); + let json = serde_json::to_string(&KeriEvent::Icp(icp)).unwrap(); assert_key_order( &json, &[ - "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "a", "x", + "v", "t", "d", "i", "s", "kt", "k", "nt", "n", "bt", "b", "x", ], ); } @@ -121,21 +122,21 @@ fn icp_with_x_includes_x_last() { fn icp_without_d_omits_d() { let mut icp = make_test_icp(); icp.d = Said::default(); - let json = serde_json::to_string(&KeriEvent::Inception(icp)).unwrap(); + let json = serde_json::to_string(&KeriEvent::Icp(icp)).unwrap(); assert!( !json.contains("\"d\":"), "d field should be omitted when empty" ); assert_key_order( &json, - &["v", "t", "i", "s", "kt", "k", "nt", "n", "bt", "b", "a"], + &["v", "t", "i", "s", "kt", "k", "nt", "n", "bt", "b"], ); } #[test] fn serialization_roundtrip_preserves_data() { let icp = make_test_icp(); - let event = KeriEvent::Inception(icp); + let event = KeriEvent::Icp(icp); let json = serde_json::to_string(&event).unwrap(); let deserialized: KeriEvent = serde_json::from_str(&json).unwrap(); assert_eq!(event, deserialized); @@ -144,7 +145,7 @@ fn serialization_roundtrip_preserves_data() { #[test] fn rot_serialization_roundtrip() { let rot = make_test_rot(); - let event = KeriEvent::Rotation(rot); + let event = KeriEvent::Rot(rot); let json = serde_json::to_string(&event).unwrap(); let deserialized: KeriEvent = serde_json::from_str(&json).unwrap(); assert_eq!(event, deserialized); @@ -153,12 +154,19 @@ fn rot_serialization_roundtrip() { #[test] fn ixn_serialization_roundtrip() { let ixn = make_test_ixn(); - let event = KeriEvent::Interaction(ixn); + let event = KeriEvent::Ixn(ixn); let json = serde_json::to_string(&event).unwrap(); let deserialized: KeriEvent = serde_json::from_str(&json).unwrap(); assert_eq!(event, deserialized); } +#[test] +fn seal_type_is_kebab_case_string() { + let seal = Seal::device_attestation("ETest"); + let json = serde_json::to_string(&seal).unwrap(); + assert!(json.contains(r#""type":"device-attestation""#)); +} + #[test] fn json_canon_golden_output() { let input = serde_json::json!({ diff --git a/crates/xtask/src/schemas.rs b/crates/xtask/src/schemas.rs index ce320881..c796acdf 100644 --- a/crates/xtask/src/schemas.rs +++ b/crates/xtask/src/schemas.rs @@ -1,8 +1,8 @@ use std::path::Path; use anyhow::Context; +use auths_keri::IcpEvent; use auths_verifier::core::{Attestation, IdentityBundle}; -use auths_verifier::keri::IcpEvent; struct SchemaSpec { name: &'static str, diff --git a/packages/auths-node/src/verify.rs b/packages/auths-node/src/verify.rs index eb32e5f0..f419abec 100644 --- a/packages/auths-node/src/verify.rs +++ b/packages/auths-node/src/verify.rs @@ -11,7 +11,7 @@ use auths_verifier::verify::{ verify_device_authorization as rust_verify_device_authorization, verify_with_capability as rust_verify_with_capability, verify_with_keys, }; -use auths_verifier::witness::{WitnessReceipt, WitnessVerifyConfig}; +use auths_verifier::witness::{Receipt, WitnessVerifyConfig}; use chrono::{DateTime, Utc}; use napi_derive::napi; @@ -384,7 +384,7 @@ pub async fn verify_chain_with_witnesses( let root_pk_bytes = decode_pk_hex(&root_pk_hex, "root public key")?; let attestations = parse_attestations(&attestations_json)?; - let receipts: Vec = receipts_json + let receipts: Vec = receipts_json .iter() .enumerate() .map(|(i, json)| { diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index 5242b727..b8746bec 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -136,6 +136,7 @@ dependencies = [ "argon2", "async-trait", "auths-crypto", + "auths-keri", "auths-pairing-protocol", "auths-verifier", "base64", @@ -195,6 +196,7 @@ dependencies = [ "async-trait", "auths-core", "auths-crypto", + "auths-keri", "auths-policy", "auths-utils", "auths-verifier", @@ -229,6 +231,7 @@ name = "auths-infra-git" version = "0.1.0" dependencies = [ "auths-core", + "auths-keri", "auths-sdk", "auths-verifier", "chrono", @@ -244,6 +247,7 @@ dependencies = [ "async-trait", "auths-core", "auths-crypto", + "auths-keri", "auths-oidc-port", "auths-verifier", "chrono", @@ -263,6 +267,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "auths-keri" +version = "0.1.0" +dependencies = [ + "async-trait", + "auths-crypto", + "base64", + "blake3", + "hex", + "ring", + "serde", + "serde_json", + "subtle", + "thiserror 2.0.18", +] + [[package]] name = "auths-oidc-port" version = "0.1.0" @@ -362,6 +382,7 @@ dependencies = [ "auths-crypto", "auths-id", "auths-infra-http", + "auths-keri", "auths-oidc-port", "auths-policy", "auths-telemetry", @@ -398,6 +419,7 @@ dependencies = [ "auths-core", "auths-crypto", "auths-id", + "auths-keri", "auths-verifier", "base64", "chrono", @@ -462,8 +484,8 @@ version = "0.1.0" dependencies = [ "async-trait", "auths-crypto", + "auths-keri", "base64", - "blake3", "bs58", "chrono", "hex", @@ -473,7 +495,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "subtle", "thiserror 2.0.18", ] diff --git a/packages/auths-python/src/verify.rs b/packages/auths-python/src/verify.rs index cbf6fca5..3ccb081c 100644 --- a/packages/auths-python/src/verify.rs +++ b/packages/auths-python/src/verify.rs @@ -10,7 +10,7 @@ use auths_verifier::verify::{ verify_device_authorization as rust_verify_device_authorization, verify_with_capability as rust_verify_with_capability, verify_with_keys, }; -use auths_verifier::witness::{WitnessReceipt, WitnessVerifyConfig}; +use auths_verifier::witness::{Receipt, WitnessVerifyConfig}; use chrono::{DateTime, Utc}; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; @@ -533,7 +533,7 @@ pub fn verify_chain_with_witnesses( }) .collect::>>()?; - let receipts: Vec = receipts_json + let receipts: Vec = receipts_json .iter() .enumerate() .map(|(i, json)| {