diff --git a/.config/nextest.toml b/.config/nextest.toml index d52ab27a2f7..85cc098e42c 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -79,6 +79,65 @@ filter = 'package(krikos-docs)' # 150s ceiling — see the profile.ci.overrides entry above for the same filter. slow-timeout = { period = "30s", terminate-after = 5 } +# This conformance test must cross the exact 256-entry lineage-retention boundary +# before proving that a late branch is authenticated from durable events. Batching +# removes repeated store-clone overhead, but the remaining signature verification +# and durable lineage reconstruction still take roughly 15-20s in isolation and +# exceeded the generic 30s ceiling twice on a contended macOS runner. Keep the +# exception exact and finite so a genuine hang still fails. +[[profile.ci.overrides]] +filter = 'package(krikos-identity) & binary(store_conformance) & test(=evicted_lineage_conflict_is_authenticated_from_durable_sources)' +slow-timeout = { period = "30s", terminate-after = 3 } + +[[profile.default.overrides]] +filter = 'package(krikos-identity) & binary(store_conformance) & test(=evicted_lineage_conflict_is_authenticated_from_durable_sources)' +slow-timeout = { period = "30s", terminate-after = 3 } + +# This interchange conformance test intentionally builds 257 fully authenticated +# provider and audit records to cross the 256-item chunk boundary, then repeats +# assembly over reordered and tampered variants. It takes roughly 55s in isolation +# and exceeded the generic ceiling on a contended Ubuntu runner. Retain a finite +# 150s ceiling for this exact boundary test. +[[profile.ci.overrides]] +filter = 'package(krikos-identity) & binary(provider_wire_formats) & test(=provider_interchange_assembles_out_of_order_and_rejects_tampering)' +slow-timeout = { period = "30s", terminate-after = 5 } + +[[profile.default.overrides]] +filter = 'package(krikos-identity) & binary(provider_wire_formats) & test(=provider_interchange_assembles_out_of_order_and_rejects_tampering)' +slow-timeout = { period = "30s", terminate-after = 5 } + +# These all-feature durability tests intentionally perform repeated authenticated +# redb commits and crash/reopen validation. The recovery test reconciles rotation, +# publication, and notification effects across every durable boundary; the +# provider tests exercise competing four-stage appends and retain fork rejection +# after reopen. They are deterministic and bounded, but repeatedly crossed the +# generic 30s ceiling on contended Ubuntu and Windows runners. Keep each exception +# exact and finite at 90s so unrelated identity tests still fail at the generic +# ceiling. +[[profile.ci.overrides]] +filter = 'package(krikos-identity) & binary(operational_recovery) & test(=finalized_recovery_effects_reconcile_across_every_durable_boundary)' +slow-timeout = { period = "30s", terminate-after = 3 } + +[[profile.default.overrides]] +filter = 'package(krikos-identity) & binary(operational_recovery) & test(=finalized_recovery_effects_reconcile_across_every_durable_boundary)' +slow-timeout = { period = "30s", terminate-after = 3 } + +[[profile.ci.overrides]] +filter = 'package(krikos-identity) & binary(provider_persistence) & test(=concurrent_redb_appends_are_linearizable_and_duplicate_idempotent)' +slow-timeout = { period = "30s", terminate-after = 3 } + +[[profile.default.overrides]] +filter = 'package(krikos-identity) & binary(provider_persistence) & test(=concurrent_redb_appends_are_linearizable_and_duplicate_idempotent)' +slow-timeout = { period = "30s", terminate-after = 3 } + +[[profile.ci.overrides]] +filter = 'package(krikos-identity) & binary(provider_persistence) & test(=redb_checkpoint_index_reopens_without_selecting_a_longer_fork)' +slow-timeout = { period = "30s", terminate-after = 3 } + +[[profile.default.overrides]] +filter = 'package(krikos-identity) & binary(provider_persistence) & test(=redb_checkpoint_index_reopens_without_selecting_a_longer_fork)' +slow-timeout = { period = "30s", terminate-after = 3 } + [[profile.default.overrides]] filter = 'test(::run_in_isolation::)' test-group = 'run-in-isolation' diff --git a/.gitattributes b/.gitattributes index 5424b2dcc4a..c692c487786 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,4 +18,9 @@ vendor/** -text **/CHANGELOG.md linguist-generated docs/history/CHANGELOG_old.md linguist-generated **/Cargo.lock linguist-generated -docs/testing/resource-canary/** linguist-generated \ No newline at end of file +docs/testing/resource-canary/** linguist-generated + +# Canonical identity fixtures and fuzz corpora are byte strings, even when a +# particular seed happens to be valid UTF-8. Never normalize or text-diff them. +protocols/krikos-identity/tests/vectors/*.bin binary linguist-generated +fuzz/corpus/identity_*/* binary linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44bf0b4de5d..0c97fc144cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ env: RUST_BACKTRACE: 1 RUSTFLAGS: -Dwarnings RUSTDOCFLAGS: -Dwarnings - MSRV: "1.91" + MSRV: "1.91.0" SCCACHE_CACHE_SIZE: "10G" KRIKOS_FORCE_STAGING_RELAYS: "1" @@ -38,24 +38,42 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - - name: Install Rust stable + - name: Install identity MSRV toolchain uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + toolchain: ${{ env.MSRV }} + - name: Install ripgrep + run: | + sudo apt-get update + sudo apt-get install --yes ripgrep - name: Prove experimental publication gate is closed run: python3 scripts/check-framework-release-gate.py --expect-closed + - name: Prove identity stable-release gate is closed + run: python3 scripts/check-identity-release-gate.py --expect-closed - name: Inspect provisional package layouts run: scripts/check-framework-package-layout.sh + - name: Check identity Rust 1.91.0 feature and dependency matrix + run: scripts/check-identity-feature-matrix.sh - name: Test framework lifecycle, persistence, and fuzz facades - run: cargo test -p krikos-app --all-features + run: cargo test --locked -p krikos-app --all-features + - name: Check deterministic identity interoperability vectors + run: scripts/check-identity-interop-vectors.sh + - name: Check complete identity canonical decoder inventory + run: scripts/check-identity-wire-inventory.sh + - name: Check normative identity documentation links + run: scripts/check-identity-doc-links.py - name: Replay imported persistent-store migrations - run: cargo test -p krikos-docs migration + run: cargo test --locked -p krikos-docs migration - name: Test direct and local-relay two-node acceptance - run: cargo test -p krikos-local-first-app-tests --test two_node + run: cargo test --locked -p krikos-local-first-app-tests --test two_node - name: Check blobs v0.103 bidirectional interoperability run: scripts/tests/check-blobs-v0-interop.sh - name: Check gossip v0.101 bidirectional interoperability run: scripts/tests/check-gossip-v0-interop.sh - name: Check local-first CI contract run: scripts/tests/check-local-first-framework-ci.sh + - name: Check identity stable-release gate contract + run: scripts/tests/check-identity-release-gate.sh fuzz_smoke: name: Fuzz smoke (${{ matrix.target }}) @@ -78,6 +96,15 @@ jobs: - app_protocol_registration - blob_ticket - doc_ticket + - identity_foundation + - identity_schema + - identity_capability + - identity_merkle + - identity_state + - identity_pairing + - identity_sync + - identity_provider + - identity_semantics steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -116,8 +143,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install Rust stable + - name: Install Rust 1.91.0 uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + toolchain: "1.91.0" + components: clippy - name: Install sccache uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 continue-on-error: true @@ -188,23 +218,27 @@ jobs: - name: Check relay V1/V2 golden compatibility run: scripts/tests/check-relay-compatibility.sh --golden - name: Test runtime capability contracts - run: cargo test -p krikos-runtime + run: cargo test --locked -p krikos-runtime - name: Test generic resolver contracts - run: cargo test -p krikos-resolver --all-features + run: cargo test --locked -p krikos-resolver --all-features - name: Test manifest, trace, and replay contracts - run: cargo test --manifest-path krikos-sim/Cargo.toml + run: cargo test --locked --manifest-path krikos-sim/Cargo.toml + - name: Check identity account-control model + run: scripts/check-identity-model.sh + - name: Replay reviewed identity corpus + run: cargo run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- identity corpus-test krikos-sim/identity-corpus - name: Validate simulation operations and cross-backend parity policy - run: cargo test --manifest-path krikos-sim/Cargo.toml --test operations --test parity + run: cargo test --locked --manifest-path krikos-sim/Cargo.toml --test operations --test parity - name: Execute the complete reviewed regression corpus - run: cargo run --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- corpus test krikos-sim/corpus + run: cargo run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- corpus test krikos-sim/corpus - name: Test Krikos runtime adapter - run: cargo test -p krikos --lib runtime --all-features + run: cargo test --locked -p krikos --lib runtime --all-features - name: Check native minimal feature graph - run: cargo check -p krikos --no-default-features + run: cargo check --locked -p krikos --no-default-features - name: Check simulation code quality run: | - cargo clippy -p krikos-runtime -p krikos --all-targets --all-features -- -D warnings - cargo clippy --manifest-path krikos-sim/Cargo.toml --all-targets --all-features -- -D warnings + cargo clippy --locked -p krikos-runtime -p krikos --all-targets --all-features -- -D warnings + cargo clippy --locked --manifest-path krikos-sim/Cargo.toml --all-targets --all-features -- -D warnings simulation_gate: name: Deterministic simulation change gate @@ -685,7 +719,7 @@ jobs: RUSTFLAGS: "" RUSTC_WRAPPER: "sccache" SCCACHE_GHA_ENABLED: "on" - RUSTDOCFLAGS: --cfg docsrs + RUSTDOCFLAGS: "-Dwarnings --cfg krikos_docsrs" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master @@ -698,8 +732,8 @@ jobs: - name: Docs run: | - cargo doc --workspace --all-features --no-deps --document-private-items - cargo doc --manifest-path krikos-sim/Cargo.toml --all-features --no-deps --document-private-items + cargo doc --locked --workspace --all-features --no-deps --document-private-items + cargo doc --locked --manifest-path krikos-sim/Cargo.toml --all-features --no-deps --document-private-items clippy_check: timeout-minutes: 30 @@ -762,6 +796,7 @@ jobs: - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: toolchain: ${{ env.MSRV }} + components: clippy - name: Install sccache uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 continue-on-error: true @@ -769,23 +804,39 @@ jobs: - name: Check MSRV all features run: | - cargo "+$MSRV" check --workspace --all-targets --all-features + cargo "+$MSRV" check --locked --workspace --all-targets --all-features + + - name: Test identity at MSRV + run: | + cargo "+$MSRV" test --locked -p krikos-identity --no-default-features --all-targets + cargo "+$MSRV" test --locked -p krikos-identity --all-features --all-targets + + - name: Lint identity at MSRV + run: | + cargo "+$MSRV" clippy --locked -p krikos-identity --no-default-features --all-targets -- -D warnings + cargo "+$MSRV" clippy --locked -p krikos-identity --all-features --all-targets -- -D warnings + + - name: Check identity documentation at MSRV + run: | + RUSTDOCFLAGS='-Dwarnings' cargo "+$MSRV" doc --locked -p krikos-identity --all-features --no-deps + cargo "+$MSRV" test --locked -p krikos-identity --no-default-features --doc + cargo "+$MSRV" test --locked -p krikos-identity --all-features --doc - name: Check MSRV — simulator run: | - cargo "+$MSRV" check --manifest-path krikos-sim/Cargo.toml --all-targets --all-features + cargo "+$MSRV" check --locked --manifest-path krikos-sim/Cargo.toml --all-targets --all-features - name: Check MSRV — blobs v0.103 interop run: | - cargo "+$MSRV" check --manifest-path compat/iroh-blobs-v0-103-interop/Cargo.toml --all-targets --all-features + cargo "+$MSRV" check --locked --manifest-path compat/iroh-blobs-v0-103-interop/Cargo.toml --all-targets --all-features - name: Check MSRV — gossip v0.101 interop run: | - cargo "+$MSRV" check --manifest-path compat/iroh-gossip-v0-101-interop/Cargo.toml --all-targets --all-features + cargo "+$MSRV" check --locked --manifest-path compat/iroh-gossip-v0-101-interop/Cargo.toml --all-targets --all-features - name: Check MSRV — relay v1 interop run: | - cargo "+$MSRV" check --manifest-path compat/relay-v1-interop/Cargo.toml --all-targets --all-features + cargo "+$MSRV" check --locked --manifest-path compat/relay-v1-interop/Cargo.toml --all-targets --all-features cargo_deny: timeout-minutes: 30 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 19251d60cf4..5b1adf1f98d 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -42,9 +42,9 @@ jobs: - uses: ./.github/actions/sccache-probe - name: Generate Docs - run: cargo doc --workspace --all-features --no-deps + run: cargo doc --locked --workspace --all-features --no-deps env: - RUSTDOCFLAGS: --cfg krikos_docsrs + RUSTDOCFLAGS: "-Dwarnings --cfg krikos_docsrs" - name: Deploy Docs to Preview Branch uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 53541c934d5..83d674cfd7b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -32,6 +32,15 @@ jobs: - app_protocol_registration - blob_ticket - doc_ticket + - identity_foundation + - identity_schema + - identity_capability + - identity_merkle + - identity_state + - identity_pairing + - identity_sync + - identity_provider + - identity_semantics steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ec7f9956ff..08672d85c79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,9 @@ jobs: - name: Prove provisional framework packages are excluded from publication run: python3 scripts/check-framework-release-gate.py --expect-closed + - name: Prove identity is excluded from stable publication + run: python3 scripts/check-identity-release-gate.py --expect-closed + simulation_release_readiness: name: Require simulation release evidence needs: preflight diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5b75dedd0e4..ce885ebb2f0 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,7 +23,7 @@ env: RUSTFLAGS: -Dwarnings RUSTDOCFLAGS: -Dwarnings SCCACHE_CACHE_SIZE: "10G" - CRATES_LIST: "krikos,krikos-app,krikos-base,krikos-bench,krikos-blobs,krikos-dns,krikos-dns-server,krikos-docs,krikos-gossip,krikos-relay,krikos-resolver,krikos-runtime" + CRATES_LIST: "krikos,krikos-app,krikos-base,krikos-bench,krikos-blobs,krikos-dns,krikos-dns-server,krikos-docs,krikos-gossip,krikos-identity,krikos-relay,krikos-resolver,krikos-runtime" KRIKOS_FORCE_STAGING_RELAYS: "1" NEXTEST_VERSION: "0.9.80" diff --git a/.gitignore b/.gitignore index 7650cdb3842..c6ce53a5e99 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # crates are bumped by directory rename, and an anchored rule silently # stops matching when that happens. target/ +__pycache__/ +*.py[cod] /fuzz/artifacts /logs @@ -17,3 +19,8 @@ krikos.config.toml # Agent scratch workspace (ledgers, briefs, review packages). .superpowers/ + +# Treat new Markdown files as opt-in so agent-generated notes do not clutter +# the repository. Existing tracked documentation remains tracked; add a narrow +# `!path/to/file.md` exception here when a new Markdown file should be kept. +*.md diff --git a/Cargo.lock b/Cargo.lock index b8ecb87943e..777bd2ff9e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -34,7 +44,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -44,9 +54,9 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", + "aead 0.5.2", "aes", - "cipher", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -156,6 +166,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -613,6 +635,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.5" @@ -764,8 +795,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20", + "cipher 0.5.2", + "poly1305", + "zeroize", ] [[package]] @@ -816,7 +862,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -1193,7 +1250,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1416,6 +1473,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -2457,6 +2515,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inplace-vec-builder" version = "0.1.1" @@ -2777,6 +2844,7 @@ dependencies = [ "krikos-blobs", "krikos-docs", "krikos-gossip", + "krikos-identity", "proptest", "serde", "serde_json", @@ -3076,6 +3144,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "krikos-identity" +version = "1.0.0" +dependencies = [ + "argon2", + "blake3", + "chacha20poly1305", + "curve25519-dalek", + "data-encoding", + "futures-lite", + "getrandom 0.4.3", + "hex", + "krikos", + "krikos-base", + "postcard", + "proptest", + "rand_core 0.10.1", + "redb 4.1.0", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "x25519-dalek", + "zeroize", +] + [[package]] name = "krikos-local-first-app-tests" version = "1.0.0" @@ -4093,6 +4189,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4263,6 +4370,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + [[package]] name = "polyval" version = "0.6.2" @@ -4272,7 +4389,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -4539,6 +4656,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -6183,6 +6306,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -6844,6 +6977,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" diff --git a/Cargo.toml b/Cargo.toml index a7fae8331ae..4bd9fd9877c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "protocols/krikos-blobs", "protocols/krikos-docs", "protocols/krikos-gossip", + "protocols/krikos-identity", "tools/determinism-checker", ] exclude = [ @@ -47,6 +48,7 @@ krikos-app = { version = "1.0.0", path = "framework/app" } krikos-blobs = { version = "1.0.0", path = "protocols/krikos-blobs", default-features = false } krikos-docs = { version = "1.0.0", path = "protocols/krikos-docs" } krikos-gossip = { version = "1.0.0", path = "protocols/krikos-gossip", default-features = false } +krikos-identity = { version = "1.0.0", path = "protocols/krikos-identity", default-features = false } [profile.release] debug = true diff --git a/Makefile.toml b/Makefile.toml index d4ec99bf77d..d03a15de9b1 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -15,6 +15,7 @@ CARGO_MAKE_WORKSPACE_SKIP_MEMBERS = [ "protocols/krikos-blobs", "protocols/krikos-docs", "protocols/krikos-gossip", + "protocols/krikos-identity", "tools/determinism-checker", ] @@ -52,3 +53,9 @@ description = "Prove provisional protocol/framework packages remain unpublished" workspace = false command = "python3" args = ["scripts/check-framework-release-gate.py", "--expect-closed"] + +[tasks.identity-release-gate] +description = "Prove krikos-identity remains outside the stable publication set" +workspace = false +command = "python3" +args = ["scripts/check-identity-release-gate.py", "--expect-closed"] diff --git a/docs/README.md b/docs/README.md index 735d7b08711..c7c68338fa2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,21 @@ - [Upstream protocol sync runbook](framework/upstream-sync.md) — how imported protocol packages (`krikos-blobs`, `krikos-gossip`, `krikos-docs`) are synced from their upstream release tags. - [Framework release gate](framework/release-gate.md) — the four approvals blocking `krikos-blobs`, `krikos-gossip`, `krikos-docs` and `krikos-app` from publication: what each requires, and how the gate is opened. +## Account identity and authorization + +- [Identity protocol profile](../protocols/krikos-identity/README.md) — the normative v1 + foundational, account-control, synchronization, and network-envelope profile currently documented + by the crate, including its codepoints, common bounds, and feature boundary. +- [Security and deployment](../protocols/krikos-identity/docs/security-and-deployment.md) — threat + model, invariants, deployment profiles, migration rules, and external release gates. +- [Provider operations](../protocols/krikos-identity/docs/provider-operations.md) — persistence, + recovery, compaction, auditing, incident procedure, and the normative bounded provider-portability + wire appendix. +- [Design-to-evidence map](../protocols/krikos-identity/docs/design-evidence.md) — implementation, + test, model, simulation, fuzz, and interoperability evidence. +- [Identity stable-release gate](../protocols/krikos-identity/docs/release-gate.md) — the six + independently evidenced approvals that keep `krikos-identity` unpublished. + ## Upstream protocol provenance - [Commit maps](upstream/commit-maps/) — old-to-new commit ID mappings recorded when each imported protocol package's history was rewritten into this monorepo. diff --git a/docs/architecture.md b/docs/architecture.md index b4f4e7fbee0..de40559c54a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,10 +37,11 @@ workspace. | `krikos-relay` | Relay client, server, shared wire protocol, and sessions | `krikos-base`, `krikos-resolver`, `krikos-runtime` | | `krikos` | Public endpoint and connection orchestration | `krikos-base`, `krikos-dns`, `krikos-resolver`, `krikos-relay`, `krikos-runtime` | | `krikos-dns-server` | Deployable endpoint DNS and pkarr service | `krikos-base`, `krikos-dns`, `krikos-resolver`; `krikos` only for dev/tests | +| `krikos-identity` | Deterministic account identity, authorization, recovery, and transparency protocol; optional storage/network adapters | `krikos-base`, optionally `krikos` | | `krikos-blobs` | Content-addressed storage and transfer protocol | `krikos` | | `krikos-gossip` | Topic-based broadcast protocol | `krikos-base`, optionally `krikos` | | `krikos-docs` | Local-first documents, capabilities, persistence, and synchronization | `krikos`, `krikos-base`, `krikos-blobs`, `krikos-gossip` | -| `krikos-app` | Experimental application lifecycle and standard local-first bundle | `krikos`, `krikos-base`, `krikos-blobs`, `krikos-gossip`, `krikos-docs` | +| `krikos-app` | Experimental application lifecycle, standard local-first bundle, and opt-in account-identity protocol composition | `krikos`, `krikos-base`, `krikos-blobs`, `krikos-gossip`, `krikos-docs`, optionally `krikos-identity` | | `krikos-bench` | Non-published benchmarks and resource canaries | public packages it exercises | | `determinism-checker` | Non-published source-boundary checker | no production package may depend on it | | `krikos-sim` | Deterministic model, execution, evidence, and operations | production packages; never the reverse | @@ -147,10 +148,30 @@ independent siblings in the graph, while this order gives packaging a stable seq package naming, registry ownership, a public API baseline, and the supported persistent-data schema. A platform v2 release cannot open this gate as a side effect. -`krikos-base` keeps its existing feature-weight contract for this cut: `default` enables `relay`, and -`key` also enables `relay` because key-facing endpoint/address types require relay URL support. -Consumers seeking the smallest value-type build must use `default-features = false` and then select -only the required features. +`krikos-identity` is also unpublished, but it is not one of those four imported framework-release +packages. Its repository-owned acceptance evidence must be green on the stable candidate: the +feature/dependency boundary, checked wire/vector inventory, deterministic model, bounded +fuzz/simulation corpus, persistent-provider recovery, network integration, and documentation/API +gates. That evidence is necessary but does not satisfy the six independent release approvals: +third-party security audit, independently maintained interoperability, production provider +diversity, protocol governance, public API/SemVer baseline, and persistent-schema support. The +optional `krikos-app` component registers its six account-identity ALPNs on the existing endpoint; +it does not move endpoint-secret persistence into the account store. + +`krikos-base` keeps its existing feature-weight contract for this cut: `default` enables `relay`. +The deterministic `key-types` feature exposes key, signature, endpoint-identifier, and address +types, including their relay/URL value types, without enabling `rand` or `getrandom`. `os-rng` adds +those entropy dependencies and `SecretKey::generate`; the legacy `key` feature remains a +compatibility alias for `os-rng`. `krikos-identity` disables `krikos-base` defaults and requests only +`key-types`; its own default feature set is empty, and only its explicit `os-rng` feature enables its +direct optional `getrandom` dependency. The `fs-store`, `net`, and `provider-store` integrations do +not imply `os-rng`. + +The repository gate [`scripts/check-identity-feature-matrix.sh`](../scripts/check-identity-feature-matrix.sh) +compiles the reviewed identity feature combinations and rejects forbidden dependencies in the +no-default normal dependency tree. It invokes +[`scripts/tests/check-identity-os-rng-boundary.sh`](../scripts/tests/check-identity-os-rng-boundary.sh) +to keep the manifest, source gates, explicit-RNG APIs, and ambient-entropy boundary aligned. ## Vendored dependency boundary diff --git a/docs/framework/getting-started.md b/docs/framework/getting-started.md index 5a68b62d030..8231147a7be 100644 --- a/docs/framework/getting-started.md +++ b/docs/framework/getting-started.md @@ -74,6 +74,23 @@ two-node flow. The acceptance package runs that architecture directly and throug including restart, read-capability enforcement, content verification, idempotent resync, and a custom ALPN. +## Opt-in account identity protocols + +Enable `krikos-app`'s `identity` feature and supply an `IdentityProtocolComponent` when the +application needs Krikos account pairing, sync, proposals, checkpoints, transparency gossip, or +recovery on the standard endpoint. The component receives an account `AccountStore`, service +callbacks, a verified-checkpoint view, and a cursor key, then registers the six versioned identity +ALPNs under the bundle's existing supervisor. It is default-deny until the supplied service +authorizes an operation. + +The two identity stores have deliberately different owners. `krikos-app::IdentityStore` persists +the endpoint transport secret used to establish connections. The `krikos_identity::AccountStore` +supplied to `IdentityProtocolComponent` persists account-control source records and effects; it +never receives that endpoint secret. See `framework/app/examples/identity.rs` for the minimal +ephemeral composition and the identity crate's +[`security and deployment guide`](../../protocols/krikos-identity/docs/security-and-deployment.md) +before enabling mutating service callbacks. + ## Lower-level composition Applications that need a different lifecycle may use `krikos-blobs`, `krikos-gossip`, or `krikos-docs` diff --git a/docs/identity/AccountControl.tla b/docs/identity/AccountControl.tla new file mode 100644 index 00000000000..deacafcd6be --- /dev/null +++ b/docs/identity/AccountControl.tla @@ -0,0 +1,193 @@ +--------------------------- MODULE AccountControl --------------------------- +EXTENDS Naturals, FiniteSets + +CONSTANTS Controllers, ControllerWeight + +ASSUME /\ Cardinality(Controllers) = 3 + /\ ControllerWeight \in [Controllers -> 1..2] + /\ Cardinality({c \in Controllers : ControllerWeight[c] = 2}) = 1 + /\ Cardinality({c \in Controllers : ControllerWeight[c] = 1}) = 2 + +RECURSIVE WeightOf(_) +WeightOf(controllerSet) == + IF controllerSet = {} + THEN 0 + ELSE LET c == CHOOSE member \in controllerSet : TRUE + IN ControllerWeight[c] + WeightOf(controllerSet \ {c}) + +VARIABLES active, revoked, threshold, heads, forkVisible, + lastAccepted, lastKind, lastApprovals, predecessorCount, + priorActive, priorRevoked, priorThreshold, priorHeads, + recoveryReplacement, recoveryPreviousActive + +vars == <> + +Kinds == {"Init", "AddController", "RevokeController", "ChangePolicy", + "OpenFork", "ResolveFork", "Recover"} + +TypeOK == + /\ active \in SUBSET Controllers + /\ revoked \in SUBSET Controllers + /\ active \cap revoked = {} + /\ threshold \in 1..WeightOf(active) + /\ heads \in 1..2 + /\ forkVisible \in BOOLEAN + /\ lastAccepted \in BOOLEAN + /\ lastKind \in Kinds + /\ lastApprovals \in SUBSET Controllers + /\ predecessorCount \in Nat + /\ priorActive \in SUBSET Controllers + /\ priorRevoked \in SUBSET Controllers + /\ priorThreshold \in Nat + /\ priorHeads \in 1..2 + /\ recoveryReplacement \in SUBSET Controllers + /\ recoveryPreviousActive \in SUBSET Controllers + +Init == + /\ active = Controllers + /\ revoked = {} + /\ threshold = 2 + /\ heads = 1 + /\ forkVisible = FALSE + /\ lastAccepted = FALSE + /\ lastKind = "Init" + /\ lastApprovals = {} + /\ predecessorCount = 0 + /\ priorActive = active + /\ priorRevoked = revoked + /\ priorThreshold = threshold + /\ priorHeads = heads + /\ recoveryReplacement = {} + /\ recoveryPreviousActive = {} + +Authorized(approvals) == + /\ approvals \in SUBSET active + /\ approvals \cap revoked = {} + /\ WeightOf(approvals) >= threshold + +RecordAccepted(kind, approvals, predecessors, replacement) == + /\ lastAccepted' = TRUE + /\ lastKind' = kind + /\ lastApprovals' = approvals + /\ predecessorCount' = predecessors + /\ priorActive' = active + /\ priorRevoked' = revoked + /\ priorThreshold' = threshold + /\ priorHeads' = heads + /\ recoveryReplacement' = replacement + /\ recoveryPreviousActive' = IF kind = "Recover" THEN active ELSE {} + +AddController(c, approvals) == + /\ heads = 1 + /\ c \in Controllers \ (active \cup revoked) + /\ Authorized(approvals) + /\ active' = active \cup {c} + /\ RecordAccepted("AddController", approvals, 1, {}) + /\ UNCHANGED <> + +RevokeController(c, approvals) == + /\ heads = 1 + /\ c \in active + /\ Authorized(approvals) + /\ WeightOf(active \ {c}) >= threshold + /\ active' = active \ {c} + /\ revoked' = revoked \cup {c} + /\ RecordAccepted("RevokeController", approvals, 1, {}) + /\ UNCHANGED <> + +ChangePolicy(newThreshold, approvals) == + /\ heads = 1 + /\ newThreshold \in 1..WeightOf(active) + /\ Authorized(approvals) + /\ threshold' = newThreshold + /\ RecordAccepted("ChangePolicy", approvals, 1, {}) + /\ UNCHANGED <> + +OpenFork(approvals) == + /\ heads = 1 + /\ Authorized(approvals) + /\ heads' = 2 + /\ forkVisible' = TRUE + /\ RecordAccepted("OpenFork", approvals, 1, {}) + /\ UNCHANGED <> + +ResolveFork(approvals) == + /\ heads > 1 + /\ Authorized(approvals) + /\ heads' = 1 + /\ forkVisible' = FALSE + /\ RecordAccepted("ResolveFork", approvals, heads, {}) + /\ UNCHANGED <> + +Recover(replacement, newThreshold) == + /\ heads = 1 + /\ replacement \in SUBSET Controllers + /\ replacement # {} + /\ replacement \cap revoked = {} + /\ newThreshold \in 1..WeightOf(replacement) + /\ active' = replacement + /\ revoked' = revoked \cup (active \ replacement) + /\ threshold' = newThreshold + /\ forkVisible' = FALSE + /\ RecordAccepted("Recover", {}, 1, replacement) + /\ UNCHANGED heads + +Next == + \/ \E c \in Controllers, approvals \in SUBSET Controllers : + AddController(c, approvals) + \/ \E c \in Controllers, approvals \in SUBSET Controllers : + RevokeController(c, approvals) + \/ \E newThreshold \in 1..WeightOf(Controllers), + approvals \in SUBSET Controllers : + ChangePolicy(newThreshold, approvals) + \/ \E approvals \in SUBSET Controllers : OpenFork(approvals) + \/ \E approvals \in SUBSET Controllers : ResolveFork(approvals) + \/ \E replacement \in SUBSET Controllers, + newThreshold \in 1..WeightOf(Controllers) : + Recover(replacement, newThreshold) + +RevokedControllersCannotAuthorize == + ~lastAccepted + \/ /\ lastApprovals \in SUBSET priorActive + /\ lastApprovals \cap priorRevoked = {} + +PolicyChangesUsePreviousPolicy == + ~lastAccepted \/ lastKind # "ChangePolicy" + \/ WeightOf(lastApprovals) >= priorThreshold + +ForksAreDetectable == + /\ (heads > 1) = forkVisible + /\ ~lastAccepted \/ priorHeads = 1 \/ lastKind = "ResolveFork" + +ThresholdRequirementsPreserved == + /\ threshold > 0 + /\ threshold <= WeightOf(active) + /\ active \cap revoked = {} + +RecoveryDoesNotRetainOldControllers == + ~lastAccepted \/ lastKind # "Recover" + \/ /\ active = recoveryReplacement + /\ (recoveryPreviousActive \ recoveryReplacement) \subseteq revoked + +AcceptedEventsHaveUniquePredecessor == + ~lastAccepted + \/ IF lastKind = "ResolveFork" + THEN /\ predecessorCount = priorHeads + /\ priorHeads > 1 + ELSE predecessorCount = 1 + +Safety == + /\ TypeOK + /\ RevokedControllersCannotAuthorize + /\ PolicyChangesUsePreviousPolicy + /\ ForksAreDetectable + /\ ThresholdRequirementsPreserved + /\ RecoveryDoesNotRetainOldControllers + /\ AcceptedEventsHaveUniquePredecessor + +Spec == Init /\ [][Next]_vars + +============================================================================= diff --git a/docs/identity/account-control-model.md b/docs/identity/account-control-model.md new file mode 100644 index 00000000000..e29546d0e95 --- /dev/null +++ b/docs/identity/account-control-model.md @@ -0,0 +1,72 @@ +# Account-control formal model + +`AccountControl.tla` specifies the bounded account-control projection used by the identity +hardening lane. Its concrete checked instance has exactly three controllers with asymmetric +weights `{2,1,1}` and policy thresholds in `1..=4`. Controller addition, revocation, and policy +change quantify explicit approval sets and authorize their total weight against the +pre-transition active set, revoked set, and threshold. Weight 2 alone and the two weight-1 +controllers together authorize threshold 2; either weight-1 controller alone does not. A proposed +lower threshold cannot authorize its own policy-change event. + +Recovery replaces authority through separate, abstractly valid recovery evidence. Each recovery +attempt carries both a replacement controller set and the replacement policy's retained threshold; +the relation accepts it only when the replacement weight satisfies that threshold. Recovery is +disabled while more than one head is retained and cannot serve as implicit fork resolution. +Revocation likewise cannot leave available weight below the retained policy. Every accepted +transition records its prior authority facts, exact predecessor count, and recovery replacement; +the six invariants are derived from those records rather than fixed-truth ghost booleans. + +The repository-owned authority is the independent Rust breadth-first checker: + +```sh +scripts/check-identity-model.sh +``` + +The command is hermetic and requires Rust 1.91, not a host-installed TLC binary. It structurally +validates the checked-in module's weighted authorization clauses, transition recording, recovery +threshold, and state-derived invariant clauses. A separately encoded portable-spec evaluator is +then compared exhaustively with the Rust relation for every explored state and attempt. The Rust +relation enumerates every approval subset, thresholds `1..=4`, accepted and rejected ordinary +transition, fork/resolution, replacement set, retained recovery threshold, and malformed +predecessor attempt. It fails closed at 4,096 states, 128 attempts per reachable state, or 200,000 +attempted transitions. + +This is a deliberately finite abstraction, not a proof for arbitrary controller counts. The three +controller instance preserves the policy distinctions relevant to weighted authorization: a +single high-weight controller, a coalition of low-weight controllers with equal total weight, an +insufficient low-weight approval, old-policy authorization of a proposed lower threshold, and +satisfiability after revocation and recovery. Controller and approval identity, disjoint active +and revoked sets, weighted sums, retained thresholds, one-versus-two heads, and predecessor +cardinality are modeled. Cryptographic validity, recovery-delay evidence construction, event +bytes, storage, networking, and unbounded populations are outside this formal abstraction and are +covered by the production/differential/simulation lanes. + +For each property, the JSON report emits four checked counters: total evaluations, reachable +antecedent witnesses, relevant accepted transitions, and relevant rejected adversarial attempts. +All four must be nonzero for all six properties, so a generic per-attempt counter cannot establish +non-vacuity. Six asymmetric/retained-threshold witnesses are checked separately. + +Seven negative controls mutate actual Rust transition rules: revoked authorization, policy +self-authorization, fork visibility, threshold satisfiability, recovery replacement, predecessor +cardinality, and recovery across a fork. Two more controls mutate the independently encoded +portable rules to use approval cardinality and to reset a recovery threshold. The command succeeds +only when all nine mutations are detected; this is why `scripts/check-identity-model.sh` can fail +closed on a dead property or parity rule rather than accepting fabricated post-transition records. +The TLA+ module is retained as the portable specification; running TLC remains an external +validation option and is not claimed by the hermetic command. + +The command report is accepted only when it records six structurally validated TLA+ actions, six +validated invariant definitions, nonzero semantic-parity and asymmetric-weight evidence, all nine +mutation controls, nonzero states and transitions, and all four nonzero witness counters for each +property. Run the same check through the simulator CLI with +`cargo +1.91.0 run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- identity +model-check`. + +The executable reference model in `krikos-sim/src/identity/model.rs` does not call production +transition logic. Production comparisons are isolated in `adapter.rs`, and a source-inventory test +enforces that dependency boundary. Differential checkpoints compare the stable account ID, +sequence, operation-specific epoch, normalized canonical heads and their complete predecessor +sets, authority/device/policy/migration state, group-key generation, and recipients. The v1 model +keeps migration begin non-advancing, represents recovery begin/finalize as two account events, and +treats the production application-key wrapping operation as out-of-band so it cannot invent an +extra account-log position. diff --git a/docs/release/v2-architecture-cut-readiness-2026-07-27.md b/docs/release/v2-architecture-cut-readiness-2026-07-27.md deleted file mode 100644 index f8344287743..00000000000 --- a/docs/release/v2-architecture-cut-readiness-2026-07-27.md +++ /dev/null @@ -1,97 +0,0 @@ -# V2 architecture-cut readiness — 2026-07-27 - -## Verdict - -The architecture hard cut is implemented, locally verified, and committed at -`b433041dce6fcb1f45287e4602e7cab271bf81df`. It is **not yet release-ready** because the hosted and -privileged evidence below is still pending. This record does not authorize a tag, crate -publication, GitHub release, container publication, or infrastructure change. - -Relay interoperability is retained against the sole approved upstream baseline, tag `v1.0.3` at -commit `f2eb930dda3779c6d852b72f3712aacd6e573ab1`. Golden protocol checks and live client/server -processes passed in both directions. - -## Candidate identity - -| Field | Value | -| --- | --- | -| Verification date | 2026-07-27; immutable baseline locked 2026-07-28 | -| Architecture-cut revision | `b433041dce6fcb1f45287e4602e7cab271bf81df` | -| Tree state | Architecture cut committed; baseline metadata recorded in a follow-up commit | -| Host | Linux 6.8.0-136-generic, x86_64 | -| Rust | `rustc 1.97.1 (8bab26f4f 2026-07-14)` | -| Cargo | `cargo 1.97.1 (c980f4866 2026-06-30)` | -| Rust API inventory baseline | Upstream `v1.0.3`; exact intentional v1-to-v2 findings audited | -| Post-cut Rust API baseline | `b433041dce6fcb1f45287e4602e7cab271bf81df` | - -The architecture-cut revision now identifies the exact source changes under test. Hosted evidence -must be collected from the pushed branch containing that revision and its baseline-lock follow-up -before release readiness can be claimed. - -## Implemented cut - -- Added and enforced the production dependency graph, isolated simulator workspace, and exact TLS - provider rules. -- Extracted generic DNS resolution to `krikos-resolver`; endpoint-record composition remains in - `krikos-dns`, and relay no longer depends on endpoint DNS or pkarr concerns. -- Replaced partial simulation injection with one validated `SimulationEnvironment`. -- Split endpoint, socket, relay actor, relay server/HTTP, simulator CLI, runner, and scenario-model - responsibilities behind narrow facades. -- Moved DNS-server black-box tests to package integration boundaries and retained private store - tests beside their implementation. -- Added the intentional v1-to-v2 API inventory and post-cut semver transition policy. -- Added frozen and live relay compatibility gates for exact upstream `v1.0.3`. -- Corrected DNS-server shutdown so DNS, HTTP, and store cancellation begins atomically and all - components share one absolute deadline. -- Prevented workspace feature unification from selecting the relay binary without an explicit - provider bundle, while preserving provider-neutral library embedding. - -The authoritative current design is in `docs/architecture.md`; wire obligations are in -`docs/relay-compatibility.md`; source migration is in `docs/release/v2-migration.md`. - -## Local verification evidence - -| Area | Command | Result | -| --- | --- | --- | -| Formatting | `cargo make format-check` | Pass | -| Diff hygiene | `git diff --check` | Pass | -| Architecture | `scripts/tests/check-workspace-architecture.sh` | Pass | -| Release source contracts | `scripts/tests/check-v2-release-readiness.sh`; `scripts/tests/check-release-fork-boundary.sh` | Pass | -| Semver policy source | `scripts/tests/check-v2-semver-policy.sh` | Pass | -| V1-to-v2 API audit | `scripts/run-v2-semver-checks.sh --allow-dirty` | Pass; the exact ten intentional resolver moves match `scripts/v2-api-breaks.txt` | -| Post-cut API stability | `scripts/run-v2-semver-checks.sh --allow-dirty` | Pass against `b433041dce6fcb1f45287e4602e7cab271bf81df`; no findings across all seven public crates | -| Workspace tests | `RUSTFLAGS='-D warnings --cfg skip_patchbay' cargo test --workspace --all-features` | Pass; Patchbay correctly excluded for its privileged lane | -| Simulator tests and benches | `cargo test --manifest-path krikos-sim/Cargo.toml --all-targets --all-features` | Pass | -| All-feature lint | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | Pass | -| Default-feature lint | `cargo clippy --workspace --all-targets --lib --bins --tests --benches --examples -- -D warnings` | Pass | -| No-default-feature lint | `cargo clippy --workspace --no-default-features --all-targets --lib --bins --tests --benches --examples -- -D warnings` | Pass | -| Simulator lint | `cargo clippy --manifest-path krikos-sim/Cargo.toml --all-targets --all-features -- -D warnings` | Pass | -| Minimal native graph | `cargo check -p krikos --no-default-features` | Pass | -| TLS feature graph | `scripts/tests/check-relay-tls-features.sh` | Pass for provider-neutral, Ring-only, AWS-LC-only, and providerless-binary failure cases | -| Workspace docs | `cargo doc --workspace --all-features --no-deps --document-private-items` | Pass | -| Simulator docs | `cargo doc --manifest-path krikos-sim/Cargo.toml --all-features --no-deps --document-private-items` | Pass | -| External public types | `cargo make check-external-types` | Pass; warnings are retained hidden/unused allowlist diagnostics, with zero errors | -| Dependency policy | `cargo deny check` | Pass: advisories, bans, licenses, and sources | -| Determinism inventories | `scripts/check-determinism-boundaries.sh --check`; `scripts/check-determinism-semantic.sh --check` | Pass after reviewed classification update | -| Relay golden compatibility | `scripts/tests/check-relay-compatibility.sh` | Pass against exact upstream `v1.0.3` | -| Relay live compatibility | `scripts/tests/check-relay-compatibility.sh --live` | Pass current client→v1.0.3 server and v1.0.3 client→current server | -| Release packages | `scripts/verify-release-packages.sh --allow-dirty` | Pass; all nine archives extracted and rebuilt in dependency order | - -An unmodified `cargo test --workspace --all-features` reached the Patchbay test binary and aborted -because this container cannot initialize a Linux user namespace (`write setgroups`). This is an -environment capability failure. Normal CI uses `--cfg skip_patchbay` for the ordinary workspace -lane and runs Patchbay separately after enabling unprivileged user namespaces; that privileged -lane was not reproduced locally. - -## Evidence still required from the committed candidate - -- Run the complete hosted feature/platform matrix on that same commit: MSRV, minimal versions, - Windows, macOS, Android, Wasm, cross/Wine, and the configured default/all/no-default test jobs. -- Run and retain the privileged Patchbay suite/public-parity smoke, Netsim, bounded fuzz smoke, - deterministic scheduled scenarios/soak, and production resource-canary evidence. -- Run the release workflow with publication disabled and retain package, binary, image, checksum, - SBOM, and provenance artifacts tied to the exact candidate SHA. -- Obtain repository-owner review and explicit publication/tag/container authority separately. - -Until those items are complete, the implementation is ready for review but the v2 release -checklist remains blocked. diff --git a/docs/release/v2-closure-audit-2026-07-25.md b/docs/release/v2-closure-audit-2026-07-25.md deleted file mode 100644 index 60062bb94e0..00000000000 --- a/docs/release/v2-closure-audit-2026-07-25.md +++ /dev/null @@ -1,90 +0,0 @@ -# Krikos 2.0 release-closure audit — 2026-07-25 - -> Historical pre-architecture-cut evidence. The later v2 hard cut intentionally moves generic DNS -> APIs and replaces the zero-break v1 Rust API gate with the reviewed migration inventory plus a -> strict post-cut baseline. Current release decisions use `v2-release-checklist.md` and a new -> candidate audit; relay wire compatibility still uses upstream v1.0.3. - -## Decision - -The source tree is locally ready to become a `2.0.0` release candidate. The -release itself remains **blocked** until this work is committed, pushed, and -validated by the hosted candidate gates. No crate, tag, GitHub release, or -container publication is authorized by this audit. - -This audit covers a dirty worktree based on -`96de37ab7191f58a72ee71abeafc7c11ecd9cf7a`. At audit time, local `main` and -`origin/main` both pointed to that commit and the worktree had 93 porcelain -status entries. Hosted results for that base commit do not validate the -uncommitted closure changes. - -## Locally closed - -| Area | Result | Evidence | -| --- | --- | --- | -| Version and package graph | Pass | Every publishable Krikos crate is `2.0.0`; internal requirements and production/simulator locks are consistent; the two vendored forks use their `-holon.1` identities. | -| Public API compatibility | Pass | `scripts/run-v2-semver-checks.sh` completed strict minor-level comparison with `v1.0.3` for `krikos`, `krikos-base`, `krikos-dns`, `krikos-dns-server`, and `krikos-relay`; each package passed all 196 applicable checks. | -| Release packages | Pass | `scripts/verify-release-packages.sh --allow-dirty` created, normalized, unpacked, and built all eight packages in dependency order without a hidden path or patch dependency. | -| Native binaries | Pass locally | Optimized GNU and static musl `x86_64` relay and DNS server binaries built and reported `2.0.0`. The hosted seven-target matrix remains required. | -| Containers | Pass locally | Both `linux/amd64` targets built from `docker/Dockerfile.ci` with Buildx and ran their `--version` smoke tests. Hosted `linux/arm64` evidence remains required. | -| Supply chain | Pass locally | The release workflow creates SHA-256 checksums, an SPDX 2.3 SBOM from the production lockfile, build provenance attestations, and an SBOM attestation. The pinned Syft 1.44.0 output passed the workflow's exact local schema check. | -| Formatting and workflow syntax | Pass | `scripts/run-format.sh --check`, every `scripts/tests/check-*.sh` contract, Actionlint, and ShellCheck for every changed/new shell script passed. | -| Strict lint | Pass | Root and simulator workspaces passed all-workspace, all-feature, all-target Clippy with warnings denied. | -| Test suites | Pass in isolated environment | Root and simulator all-feature/all-target test suites passed through `scripts/krikos-test-env`. Patchbay passed 46 cases with 13 explicitly ignored capability/reliability cases. | -| Deterministic testing | Pass | Boundary contracts, semantic contracts, campaigns, corpus/replay paths, fixed-seed nightly scenarios, bounded fuzz tooling, and daily soak/resource workflow contracts passed. | -| Resource hardening | Pass locally | Finite connection, request, task, session, body, retained-state, and shutdown limits are covered by regression and saturation tests. No unresolved Critical or High TigerStyle finding was found in this closure pass. | - -A direct host invocation of `scripts/run-all-tests.sh` reached Patchbay and was -then rejected by the host's user-namespace `setgroups` boundary. The same -suite passed through the repository's isolated test environment. That is an -expected host capability boundary, not a skipped release requirement. - -## External state observed - -The following read-only observations were made on 2026-07-25: - -- `holon-technologies/iroh` is public, its default branch is `main`, and the - authenticated operator has repository administration permission. -- The repository has no rulesets and GitHub reports `main` as unprotected. -- Actions are enabled for all actions. Default workflow permissions are read - only; actions may not approve pull-request reviews; SHA pinning is not - repository-enforced. -- The repository has zero self-hosted runners, Actions secrets, Actions - variables, and GitHub releases. -- crates.io returned `404` for both `krikos-noq` and - `krikos-hickory-server`. The required fork namespaces are therefore not yet - controlled by an authorized Holon publisher. -- [CI run 30155190396](https://github.com/holon-technologies/iroh/actions/runs/30155190396), - [Patchbay run 30155190120](https://github.com/holon-technologies/iroh/actions/runs/30155190120), - [Netsim run 30155190179](https://github.com/holon-technologies/iroh/actions/runs/30155190179), - and [Wine run 30155190240](https://github.com/holon-technologies/iroh/actions/runs/30155190240) - remained queued against the pre-closure base commit. Their results cannot be - used as candidate evidence. -- The repeatedly failing project-board workflow is deleted by this worktree; - the deletion does not take effect until the changes are pushed. - -## Blocking candidate gates - -These are release blockers, not implementation work that can be honestly -closed from a dirty local tree: - -1. Create one reviewed immutable candidate commit and push it to the default - branch. -2. Configure an owner-approved ruleset or branch protection policy with the - final required hosted checks. -3. Obtain green hosted evidence on that exact commit for CI, MSRV, platforms, - dependency policy, fuzz smoke, Patchbay public-parity and full namespace - coverage, Netsim, Wine, deterministic nightly/soak, and the resource canary. -4. Claim and publish `krikos-noq` and `krikos-hickory-server` in dependency order - under an authorized Holon crates.io account before publishing dependent - crates. -5. Dispatch the release workflow against the exact 40-character commit SHA - with release and container publication disabled. Retain and inspect all - native bundles, crate packages, checksums, SBOM, and Sigstore bundles. -6. Obtain explicit owner authorization before crates.io publication, the - immutable `v2.0.0` tag, draft GitHub release, or GHCR publication. -7. After publication, smoke-test crates.io installs, every release archive, - and both container architectures before publishing the draft release. - -Until all items in `docs/release/v2-release-checklist.md` are checked against -the same immutable revision, the correct release status is **not ready**. diff --git a/docs/release/v2-release-checklist.md b/docs/release/v2-release-checklist.md index d8927891eb6..05c434478dd 100644 --- a/docs/release/v2-release-checklist.md +++ b/docs/release/v2-release-checklist.md @@ -40,8 +40,11 @@ GitHub release, or container. - [ ] Formatting, clippy, docs, MSRV, dependency policy, all feature variants, minimal versions, Windows, macOS, Android, Wasm, cross, and Wine jobs are green on the exact candidate commit. -- [ ] All nine bounded fuzz smoke targets are green on the candidate: the five platform targets, - application manifest and protocol registration, blob tickets, and document tickets. +- [ ] All eighteen bounded fuzz smoke targets are green on the candidate: the five platform + targets; application manifest and protocol registration; blob and document tickets; and + `identity_foundation`, `identity_schema`, `identity_capability`, `identity_merkle`, + `identity_state`, `identity_pairing`, `identity_sync`, `identity_provider`, and + `identity_semantics`. - [ ] Deterministic simulation contracts, corpus, campaigns, replay, daily soak, and the five fixed-seed nightly scenarios are green. - [ ] The local-first application model fixture, docs migration tests, direct and forced-relay @@ -89,6 +92,26 @@ GitHub release, or container. tag and commit, retain licenses and migrations, document the fork delta, and rerun bidirectional compatibility before integration. +## Identity stable-release gate + +- [ ] `protocols/krikos-identity/release-gate.toml` remains blocked for the platform v2 release, and + `python3 scripts/check-identity-release-gate.py --expect-closed` proves the crate retains + `publish = false`, workspace membership, exclusion from the publishable package order, and + inclusion in the external-types skip list. +- [ ] The gate remains separate from the exact four-package framework gate. Repository-owned tests, + models, vectors, and operational documentation do not satisfy its external approvals by + themselves. +- [ ] Repository-owned identity acceptance is green on the immutable candidate: the reviewed + feature/dependency matrix, canonical wire/vector inventory and interoperability validation, + deterministic model, bounded simulation/fuzz corpus, persistent account/provider recovery and + reopen behavior, six-handler network integration, and documentation/API/workspace gates. This + evidence is necessary but does not set any external approval. +- [ ] Before a stable `krikos-identity` publication, every approval and evidence requirement in + `protocols/krikos-identity/docs/release-gate.md` is complete: + `third_party_security_audit`, `independently_maintained_interoperability`, + `production_provider_diversity`, `protocol_governance`, `public_api_semver_baseline`, and + `persistent_schema_support`. + ## Repository and publication authority - [ ] Required checks and branch protection/rulesets are configured for the @@ -99,6 +122,9 @@ GitHub release, or container. - [ ] Before any experimental framework publication, a repository owner separately approves all four package names, registry ownership, public API baseline, and persistent-data schema support commitments and opens the machine-readable framework gate. +- [ ] Before any stable identity publication, the designated security, protocol, operations, API, + and storage owners approve their evidence, a repository owner authorizes publication, and the + identity gate passes in `--require-open` mode. - [ ] The `krikos-noq` and `krikos-hickory-server` crates.io names are claimed by an authorized Holon publisher before dependent Krikos crates are published. - [ ] A repository owner explicitly authorizes the immutable `v1.0.0` tag, diff --git a/docs/testing/determinism-audit.md b/docs/testing/determinism-audit.md index ec0fb642667..d66625ae090 100644 --- a/docs/testing/determinism-audit.md +++ b/docs/testing/determinism-audit.md @@ -1,6 +1,6 @@ # Krikos Determinism and Testability Audit -Status: Living audit reviewed through deterministic tooling closure, 2026-07-26 +Status: Living audit reviewed through identity protocol integration, 2026-08-05 ## Scope @@ -24,17 +24,17 @@ The audit is based on source, test, manifest, and workflow inspection. Re-run th ```bash rg -n 'tokio::spawn|tokio::task::spawn|n0_future::task::spawn|task::spawn' \ - krikos krikos-base krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim --glob '*.rs' + krikos krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim protocols/krikos-identity --glob '*.rs' rg -n 'tokio::time|n0_future::time|Instant::now|SystemTime::now' \ - krikos krikos-base krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim --glob '*.rs' + krikos krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim protocols/krikos-identity --glob '*.rs' rg -n 'rand::random|rand::rng\(\)|thread_rng|OsRng|getrandom|with_jitter' \ - krikos krikos-base krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim --glob '*.rs' + krikos krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim protocols/krikos-identity --glob '*.rs' rg -n '(UdpSocket|TcpListener)::bind|resolve_host|lookup_|netmon::|interfaces::|portmapper' \ - krikos krikos-base krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim --glob '*.rs' + krikos krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim protocols/krikos-identity --glob '*.rs' rg -n 'std::fs|tokio::fs|std::env|env::var|Command::new|thread::spawn|spawn_blocking' \ - krikos krikos-base krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim --glob '*.rs' + krikos krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim protocols/krikos-identity --glob '*.rs' rg -n 'HashMap|HashSet|FxHashMap|FxHashSet' \ - krikos krikos-base krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim --glob '*.rs' + krikos krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim protocols/krikos-identity --glob '*.rs' scripts/tests/check-determinism-boundaries.sh scripts/tests/check-determinism-semantic.sh @@ -70,6 +70,43 @@ literals, resolves file-local `use` aliases, and records category, path, enclosi same-owner ordinal. Source-line movement alone therefore does not create semantic drift. Both inventories fail closed together; parse failure or drift requires explicit review and `--update`. +The 2026-08-05 identity protocol integration adds `protocols/krikos-identity` to both inventories; +the fixture tests now fail if that root disappears or if `getrandom::fill`, including a qualified +entropy function passed as a value, is not classified. Identity's default deterministic core has +no ambient entropy dependency. Every operating-system entropy convenience constructor is behind +the explicit `os-rng` feature and has an injected `*_with_rng` or material-taking counterpart. +Those `getrandom::fill` occurrences are intentional **Production randomness**: entropy failure is +typed, partial artifacts are not returned, and deterministic vectors and simulation do not enable +or call those paths. + +The optional identity `net` adapter owns its child tasks in a bounded `JoinSet` and applies one +finite shutdown timeout after cancellation. Task scheduling and that deadline are +**Behavioral randomness in the production-runtime adapter**, isolated from canonical transition +logic and the deterministic identity simulator. The adjacent relay-only, admission, and shutdown +timeouts/sleep are **Acceptable nondeterminism in regression-test orchestration**. The provider +concurrency threads likewise exist only in a linearizability regression. The lexical +`lookup_handles_*` test-name match is a false positive; it performs no network or host lookup. + +Identity redb validation opens only an explicit caller-selected path behind `fs-store` or +`provider-store`; the provider-auditor example reads an explicit operator-selected artifact. +These are **Acceptable nondeterminism in explicit persistence and executable orchestration**, not +inputs to the deterministic transition core. Identity simulator canonicalization, bounded +artifact reads/writes, temporary directories, and child CLI processes are also +**Acceptable nondeterminism in executable or regression-test orchestration**: canonical bytes and +hashes are validated before replay, and host filesystem or process order cannot affect a scenario +after construction. Adding the scenario input-size regression moved only the later test-orchestration +line numbers; the lexical baseline was refreshed after the checker classified that change as pure +line drift, without any new boundary occurrence. + +The same refresh classifies two non-identity deltas. `Builder::bind` passing +`SecretKey::generate` as a function value is the existing secure default **Production +randomness** boundary, newly visible to the semantic checker; simulation supplies an explicit +key. The transfer example's finite write deadline is **Acceptable nondeterminism in a manual/CI +example**, outside deterministic execution. Endpoint connection timer/task rows and +`krikos-base`'s `rand::random` row only moved. Four test-only `SecretKey::generate` calls were +replaced by fixed keys, reducing test nondeterminism; the new `os-rng` crate-doc match is prose, +not executable code. + The 2026-07-21 Stage 1 review added the `krikos-runtime` production adapters and moved existing Krikos occurrences as context plumbing and tests were introduced. `TokioClock` and `SystemWallClock` are classified as production implementations behind injectable clock traits; `RootSeed::random` is the single production behavioral-seed boundary; and `EndpointConfig::rng_seed` is now supplied from the explicit per-endpoint `endpoint//noq` decision stream. Endpoint identities, TLS token keys, and QUIC reset keys continue to use cryptographic entropy and were not routed through behavioral decisions. The 2026-07-21 parity refresh added one test-only environment read in @@ -145,8 +182,9 @@ files through one 64 MiB bounded reader. Corpus enumeration rejects the first en and the first per-entry file above its exact two-file schema; replay validates that the declared chunk count equals the finite indexed chunk set before iterating. These are **Acceptable nondeterminism in simulator process orchestration** and cannot affect a simulated protocol -decision. The occurrence checker now recognizes imported `File::open` and `OpenOptions` spellings -as external-state boundaries, closing a prior manifest blind spot. New fixture writes are +decision. Identity scenario parsing additionally rejects an encoded input above 4 MiB before JSON +deserialization. The occurrence checker now recognizes imported `File::open` and `OpenOptions` +spellings as external-state boundaries, closing a prior manifest blind spot. New fixture writes are **Acceptable nondeterminism in regression-test orchestration**; other changed rows are line movement from routing reads through the bounded helper. diff --git a/docs/testing/simulation.md b/docs/testing/simulation.md index b4181c49e27..8c5052b616a 100644 --- a/docs/testing/simulation.md +++ b/docs/testing/simulation.md @@ -99,10 +99,98 @@ cargo run --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- --help ``` The stable command surface is `run`, `campaign`, `soak`, `gate-select`, `replay`, `minimize`, -`corpus`, `explain`, and `parity`. Stage 6 activates deterministic execution across direct IP, +`corpus`, `explain`, `parity`, and `identity`. Stage 6 activates deterministic execution across direct IP, NAT/firewall, discovery, mobility, relay lifecycle, relay/direct paths, and seeded production-task ready ordering. +### Identity hardening lane + +The identity lane has a separate strict schema because account-control operations are not network +topology actions. It still uses the same `Kernel`, seeded ready-task scheduler, injected virtual +clock, `RootSeed`, trace schema, source-bound manifest, and immutable artifact store. Every action, +including a fault-only or rejected action, is a kernel-owned task and evaluates all twelve identity +invariants. A report is invalid unless each invariant counter equals the executed step count. +Scenario validation rejects zero identities or weights, duplicate or oversized authority lists, +unsatisfied or arithmetically unrepresentable recovery declarations, excess fork branches, +undeclared fork-resolution targets, and replica/provider bounds before the kernel is constructed. +The simulator-only `invariant_fault` action mutates independently captured oracle evidence; it +exists solely to exercise invariant failure capture and is visibly retained in canonical scenarios +and replay artifacts. + +An action defaults to a required-success terminal, which is omitted from canonical JSON. A +semantically invalid transition may instead declare an `expectation` with terminal +`model_rejection` and one exact stable model-error discriminant. Only the exact declared rejection +is classified as a correct non-product terminal. An unmarked or mismatched model error, or a +declared rejection that unexpectedly succeeds, remains a product failure; semantically impossible +expectation/action pairings are rejected during scenario validation. Correct expected rejections +write a versioned `identity-rejection-report.json` with exact report and trace evidence and replay +through that terminal. They are never confirmed, action-deletion minimized, labeled as product +failures, or staged for corpus promotion. + +The invariant observations distinguish the transition that first exposes a fork from later actions +that retain its complete head set. Provider/storage faults and freshness probes may run while the +fork remains visible, and only an explicit authorized resolution may reduce it. Offline validation +must report the exact captured `sequence/epoch` basis (or `no_basis` while forked), and the model's +persisted state contains no ordinary-account private-key inventory. The invariant oracle carries a +transient typed recipient set, initialized empty, only so the negative-control mutation can prove +that forbidden private-key replication would be detected; that set is neither model authority nor +durable simulator inventory. + +The two reviewed corpus histories have distinct fixed seeds and jointly cover partition/heal, +delay/reorder/loss/duplicate, concurrent children and explicit fork resolution, crash/reopen with +storage loss, provider outage/equivocation, recovery, controller and device revocation, migration, +and group-recipient rotation. Corpus loading rejects extra files, duplicate IDs or seeds, +unreviewed entries, ID mismatch, and incomplete coverage. Confirmed failures are minimized only by +bounded action deletion, and a candidate is retained only when it reproduces the exact normalized +failure class and evidence digest. + +Failure-bundle schema v2 indexes the original and minimized scenarios, terminal reports, raw +traces, normalized traces, confirmation record, signature, and complete reduction transcript. +Replay executes both scenarios twice, requires their exact common signature/report/trace evidence, +checks canonical scenario digests, and reconstructs every deletion candidate through the real +runner. Recomputing the file index cannot make a substituted original scenario, confirmation +digest, or candidate digest semantically valid. Promotion records the verified artifact-index root +and remains unreviewed until an explicit corpus review. + +This minimized promotion lifecycle applies only to identity scenario-runner product-failure +terminals. Differential checks use an explicit retained `RootSeed` (the fixed CI seed is shown +below), while the formal checker is deterministic and seedless; those two lanes fail CI with their +bounded witness and are reproduced by their exact command rather than being action-deletion +minimized. + +Run a scenario, replay its report plus raw and normalized traces byte-for-byte, check the corpus, +and compare a generated production/reference history with: + +```bash +cargo +1.91.0 run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- identity run \ + krikos-sim/identity-corpus/network-storage-provider.json \ + --seed 8181818181818181818181818181818181818181818181818181818181818181 \ + --artifacts /tmp/krikos-identity-run + +cargo +1.91.0 run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- identity replay \ + /tmp/krikos-identity-run/manifest.json + +cargo +1.91.0 run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- identity corpus-test \ + krikos-sim/identity-corpus + +cargo +1.91.0 run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- identity differential \ + --seed 7373737373737373737373737373737373737373737373737373737373737373 +``` + +The executable reference model owns its controller, device, policy, fork, recovery, migration, and +group-recipient transitions. It cannot import production identity code; a source-inventory test +enforces that only the differential adapter may do so. Migration observations come from production +lifecycle and checkpoint crypto commitments, including successful new-suite and rejected old-suite +probes after retirement. Group recipients come from the production post-state distribution +snapshot and deterministic key wrapping, not from the expected model projection. + +This lane substitutes deterministic test cryptography and records that fidelity exception. It +does not claim external provider realism, external TLC execution, independent language +interoperability, or a production security audit. Encoded scenario inputs are rejected above 4 MiB +before JSON deserialization and are further bounded to 256 actions and 60 seconds of virtual time; +the model, queue, replica, provider, kernel event, task, trace, corpus, minimizer, and formal-state +collections all have explicit fail-closed bounds. + Run either checked-in Stage 2 scenario with an explicit behavioral seed: ```bash diff --git a/docs/testing/stage1-performance-sample.md b/docs/testing/stage1-performance-sample.md deleted file mode 100644 index db2b6ac6a6f..00000000000 --- a/docs/testing/stage1-performance-sample.md +++ /dev/null @@ -1,55 +0,0 @@ -# Stage 1 Performance Sample - -This is a non-gating smoke baseline captured on 2026-07-21 while implementing Stage 1. It proves the existing release benchmark remains runnable through the new production runtime adapters; a single local sample is not a statistically valid regression threshold. - -## Environment - -- Source base: `f2eb930dda`; dirty implementation worktree status digest at capture: `4ce814314876aaf17e686306c19c9df6199b4454321d684ffe6219e699d111ad` -- OS: Ubuntu Linux, kernel `6.8.0-134-generic`, x86-64 -- CPU allocation: 4 cores, AMD Ryzen 7 8845HS -- Memory: 22 GiB -- Build: Cargo release profile, default `krikos-bench` features unless noted - -## Direct IP sample - -```bash -cargo run --release -p krikos-bench --bin bulk -- \ - krikos --download-size 16M --streams 4 --max_streams 4 -``` - -- Connection: 7.42 ms -- Aggregate: 64 MiB in 90.60 ms, 706.42 MiB/s -- Per-stream median: 178.62 MiB/s, 89 ms - -## Local relay-only sample - -The first attempt found a pre-existing benchmark-harness assumption: `--only-relay` clears IP transports, but the harness indexed the first bound IP socket. The harness now adds a direct address only when one exists, allowing the documented relay-only mode to run. - -```bash -cargo run --release -p krikos-bench --bin bulk --features local-relay -- \ - krikos --download-size 4M --streams 2 --max_streams 2 --only-relay -``` - -- Connection: 252.55 ms -- Aggregate: 8 MiB in 117.20 ms, 68.26 MiB/s -- Per-stream median: 34.47 MiB/s, 115 ms - -Both samples printed the benchmark's existing “Endpoint dropped without calling `Endpoint::close`” server teardown diagnostic. That harness cleanup issue is retained as follow-up evidence and is not attributed to the runtime capability path. - -## Interpretation - -No pass/fail threshold is established from these observations. A performance gate needs repeated samples on a stable runner, raw artifact retention, confidence intervals, and direct comparison with a clean base-revision build using identical commands and feature sets. Connection-establishment, packet throughput, and local relay paths are represented here; future benchmark work should separate endpoint construction and steady-state relay-server measurements. - -## Stage 2 direct-IP comparison - -The exact direct-IP command above was rerun after the Stage 2 injected socket boundary was in -place. One build-and-run sample and three immediately repeated executions produced aggregate -throughput of 571.36, 678.35, 559.10, and 771.15 MiB/s. The four-sample median was 624.86 MiB/s, -with connection times between 8.04 and 9.94 ms. The earlier single Stage 1 observation was -706.42 MiB/s and 7.42 ms. - -The spread within the Stage 2 samples is larger than their median difference from the lone Stage -1 sample, so this evidence does not establish a regression. It does establish that the normal OS -socket/default-builder path remains functional after capability injection. A defensible gate still -requires interleaved clean-base and candidate trials on an isolated runner. The existing endpoint -teardown diagnostic appeared in every sample and remains a benchmark-harness issue. diff --git a/framework/app/Cargo.toml b/framework/app/Cargo.toml index eb0d4c2b37f..84f72ee4c0d 100644 --- a/framework/app/Cargo.toml +++ b/framework/app/Cargo.toml @@ -10,6 +10,7 @@ publish = false [features] fuzzing = [] +identity = ["dep:krikos-identity"] [lints] workspace = true @@ -20,6 +21,7 @@ krikos-base = { workspace = true, features = ["key"] } krikos-blobs = { workspace = true, default-features = false, features = ["fs-store"] } krikos-docs = { workspace = true, features = ["fs-store"] } krikos-gossip = { workspace = true, default-features = false, features = ["net"] } +krikos-identity = { workspace = true, default-features = false, features = ["net"], optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" @@ -30,3 +32,7 @@ tokio-util = { version = "0.7", features = ["rt"] } proptest = "1.11.0" tempfile = "3.27.0" tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync", "test-util", "time"] } + +[[example]] +name = "identity" +required-features = ["identity"] diff --git a/framework/app/examples/identity.rs b/framework/app/examples/identity.rs new file mode 100644 index 00000000000..597014fa5f5 --- /dev/null +++ b/framework/app/examples/identity.rs @@ -0,0 +1,50 @@ +//! Starts the standard bundle with the opt-in, default-deny identity protocol component. + +use std::sync::Arc; + +use krikos_app::{IdentityProtocolComponent, StandardBundle}; +use krikos_identity::{ + AccountId, CheckpointId, CursorKey, DeviceId, IdentityError, MemoryAccountStore, + net::DenyIdentityProtocolService, + transport::{CheckpointDeviceEndpoint, VerifiedCheckpointView}, +}; + +#[derive(Debug)] +struct EmptyCheckpointView; + +impl VerifiedCheckpointView for EmptyCheckpointView { + fn device_endpoint( + &self, + _account_id: AccountId, + _checkpoint_id: CheckpointId, + _device_id: DeviceId, + ) -> Result, IdentityError> { + Ok(None) + } +} + +fn main() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("example runtime must build") + .block_on(async { + let identity = IdentityProtocolComponent::new( + Arc::new(MemoryAccountStore::new()), + Arc::new(DenyIdentityProtocolService), + Arc::new(EmptyCheckpointView), + CursorKey::new([7; 32]).expect("constant cursor key is nonzero"), + ); + let application = StandardBundle::ephemeral() + .local_only() + .identity_protocols(identity) + .start() + .await + .expect("identity-enabled application must start"); + println!("identity endpoint: {}", application.endpoint_id()); + application + .shutdown() + .await + .expect("identity-enabled application must stop"); + }); +} diff --git a/framework/app/src/identity_protocol.rs b/framework/app/src/identity_protocol.rs new file mode 100644 index 00000000000..9f198d51c43 --- /dev/null +++ b/framework/app/src/identity_protocol.rs @@ -0,0 +1,88 @@ +//! Opt-in account-identity protocol composition for the standard application bundle. + +use std::{fmt, sync::Arc}; + +use krikos::Endpoint; +use krikos_identity::{ + AccountStore, CursorKey, + net::{IdentityProtocolHandlers, IdentityProtocolKind, IdentityProtocolService}, + transport::VerifiedCheckpointView, +}; + +use crate::{ProtocolRegistry, RegistryError}; + +/// Account-identity protocol dependencies composed with the application's existing endpoint. +/// +/// This component owns no endpoint secret. `krikos-app::IdentityStore` remains the sole endpoint +/// key persistence boundary; the account store supplied here contains Krikos account source +/// records only. +pub struct IdentityProtocolComponent { + account_store: Arc, + service: Arc, + checkpoints: Arc, + cursor_key: CursorKey, +} + +impl fmt::Debug for IdentityProtocolComponent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityProtocolComponent") + .finish_non_exhaustive() + } +} + +impl IdentityProtocolComponent { + /// Compose account source storage, operational callbacks, checkpoint lookup, and cursor key. + pub fn new( + account_store: Arc, + service: Arc, + checkpoints: Arc, + cursor_key: CursorKey, + ) -> Self { + Self { + account_store, + service, + checkpoints, + cursor_key, + } + } + + pub(crate) fn register( + self, + _endpoint: &Endpoint, + protocols: &mut ProtocolRegistry, + ) -> Result<(), RegistryError> { + let handlers = IdentityProtocolHandlers::new( + self.service, + self.checkpoints, + self.account_store, + self.cursor_key, + ); + for kind in [ + IdentityProtocolKind::Pairing, + IdentityProtocolKind::Sync, + IdentityProtocolKind::Proposal, + IdentityProtocolKind::Checkpoint, + IdentityProtocolKind::TransparencyGossip, + IdentityProtocolKind::Recovery, + ] { + protocols.register(kind.alpn(), handlers.handler(kind))?; + } + Ok(()) + } +} + +pub(crate) const IDENTITY_PROTOCOL_COUNT: usize = 6; + +pub(crate) fn is_identity_alpn(alpn: &[u8]) -> bool { + [ + IdentityProtocolKind::Pairing, + IdentityProtocolKind::Sync, + IdentityProtocolKind::Proposal, + IdentityProtocolKind::Checkpoint, + IdentityProtocolKind::TransparencyGossip, + IdentityProtocolKind::Recovery, + ] + .into_iter() + .any(|kind| kind.alpn() == alpn) +} diff --git a/framework/app/src/lib.rs b/framework/app/src/lib.rs index 31228f69a03..153a0264f3f 100644 --- a/framework/app/src/lib.rs +++ b/framework/app/src/lib.rs @@ -10,6 +10,8 @@ mod config; mod data_root; mod error; mod identity; +#[cfg(feature = "identity")] +mod identity_protocol; mod lifecycle; mod protocol_registry; mod standard_bundle; @@ -27,6 +29,8 @@ pub use error::{ StandardStartStage, StartupError, WaitError, }; pub use identity::{FileIdentityStore, IdentityPolicy, IdentityStore, MemoryIdentityStore}; +#[cfg(feature = "identity")] +pub use identity_protocol::IdentityProtocolComponent; pub use lifecycle::{ AppBuilder, Component, ComponentContext, ComponentFuture, ConfiguredApp, LifecycleState, StartedComponent, diff --git a/framework/app/src/standard_bundle.rs b/framework/app/src/standard_bundle.rs index fde7c25b047..7cc6fd5e630 100644 --- a/framework/app/src/standard_bundle.rs +++ b/framework/app/src/standard_bundle.rs @@ -15,6 +15,10 @@ use krikos_blobs::{BlobsProtocol, api::Store as BlobsStore}; use krikos_docs::protocol::Docs; use krikos_gossip::net::Gossip; +#[cfg(feature = "identity")] +use crate::identity_protocol::{ + IDENTITY_PROTOCOL_COUNT, IdentityProtocolComponent, is_identity_alpn, +}; use crate::{ AppBuilder, AppConfig, Application, ApplicationMetrics, Component, ComponentContext, ComponentError, ComponentFuture, DataRoot, FileIdentityStore, IdentityPolicy, IdentityStore, @@ -65,6 +69,8 @@ pub struct StandardBundleBuilder { ca_tls_config: Option, config: AppConfig, custom_protocols: ProtocolRegistry, + #[cfg(feature = "identity")] + identity_protocols: Option, } impl StandardBundleBuilder { @@ -80,6 +86,8 @@ impl StandardBundleBuilder { ca_tls_config: None, config, custom_protocols, + #[cfg(feature = "identity")] + identity_protocols: None, } } @@ -139,6 +147,16 @@ impl StandardBundleBuilder { Ok(self) } + /// Installs the six account-identity handlers on the bundle's already-resolved endpoint. + /// + /// The supplied account component does not load, replace, or persist the endpoint secret. + #[cfg(feature = "identity")] + #[must_use] + pub fn identity_protocols(mut self, component: IdentityProtocolComponent) -> Self { + self.identity_protocols = Some(component); + self + } + /// Starts stores and networking in dependency order and publishes only a complete handle. pub async fn start(self) -> Result { self.validate_protocols()?; @@ -243,6 +261,10 @@ impl StandardBundleBuilder { &gossip, &docs, self.custom_protocols, + #[cfg(feature = "identity")] + self.identity_protocols, + #[cfg(feature = "identity")] + &endpoint, ); if registry_result.is_err() { cleanup_without_router(&endpoint, &blobs, &gossip, Some(&docs)).await; @@ -304,10 +326,31 @@ impl StandardBundleBuilder { "application configuration is invalid", ) })?; - let total = self + let standard_total = self .custom_protocols .len() - .saturating_add(STANDARD_PROTOCOL_COUNT); + .checked_add(STANDARD_PROTOCOL_COUNT) + .ok_or_else(|| { + StandardStartError::new( + StandardStartStage::ProtocolRegistry, + "protocol count overflowed", + ) + })?; + #[cfg(feature = "identity")] + let total = if self.identity_protocols.is_some() { + standard_total + .checked_add(IDENTITY_PROTOCOL_COUNT) + .ok_or_else(|| { + StandardStartError::new( + StandardStartStage::ProtocolRegistry, + "identity protocol count overflowed", + ) + })? + } else { + standard_total + }; + #[cfg(not(feature = "identity"))] + let total = standard_total; if total > self.config.protocol_limit { return Err(StandardStartError::new( StandardStartStage::ProtocolRegistry, @@ -331,10 +374,16 @@ fn register_all_protocols( gossip: &Gossip, docs: &Docs, custom: ProtocolRegistry, + #[cfg(feature = "identity")] identity: Option, + #[cfg(feature = "identity")] endpoint: &Endpoint, ) -> Result<(), RegistryError> { protocols.register(krikos_blobs::ALPN, BlobsProtocol::new(blobs, None))?; protocols.register(krikos_gossip::ALPN, gossip.clone())?; protocols.register(krikos_docs::ALPN, docs.clone())?; + #[cfg(feature = "identity")] + if let Some(identity) = identity { + identity.register(endpoint, protocols)?; + } for (alpn, handler) in custom.into_handlers() { protocols.register_dyn(alpn, handler)?; } @@ -387,7 +436,16 @@ async fn cleanup_without_router( } fn is_standard_alpn(alpn: &[u8]) -> bool { - alpn == krikos_blobs::ALPN || alpn == krikos_docs::ALPN || alpn == krikos_gossip::ALPN + alpn == krikos_blobs::ALPN || alpn == krikos_docs::ALPN || alpn == krikos_gossip::ALPN || { + #[cfg(feature = "identity")] + { + is_identity_alpn(alpn) + } + #[cfg(not(feature = "identity"))] + { + false + } + } } #[derive(Debug)] diff --git a/framework/app/tests/identity_component.rs b/framework/app/tests/identity_component.rs new file mode 100644 index 00000000000..0b641c426a7 --- /dev/null +++ b/framework/app/tests/identity_component.rs @@ -0,0 +1,85 @@ +#![cfg(feature = "identity")] + +use std::sync::Arc; + +use krikos_app::{DataRoot, IdentityProtocolComponent, StandardBundle}; +use krikos_identity::{ + AccountId, CheckpointId, CursorKey, DeviceId, IdentityError, MemoryAccountStore, + net::DenyIdentityProtocolService, + transport::{CheckpointDeviceEndpoint, VerifiedCheckpointView}, +}; + +#[derive(Debug)] +struct EmptyCheckpointView; + +impl VerifiedCheckpointView for EmptyCheckpointView { + fn device_endpoint( + &self, + _account_id: AccountId, + _checkpoint_id: CheckpointId, + _device_id: DeviceId, + ) -> Result, IdentityError> { + Ok(None) + } +} + +fn component(seed: u8) -> IdentityProtocolComponent { + IdentityProtocolComponent::new( + Arc::new(MemoryAccountStore::new()), + Arc::new(DenyIdentityProtocolService), + Arc::new(EmptyCheckpointView), + CursorKey::new([seed; 32]).unwrap(), + ) +} + +#[tokio::test] +async fn opt_in_component_registers_six_handlers_on_existing_endpoint() { + let application = StandardBundle::ephemeral() + .local_only() + .identity_protocols(component(7)) + .start() + .await + .unwrap(); + for expected in [ + krikos_identity::transport::PAIRING_ALPN, + krikos_identity::transport::SYNC_ALPN, + krikos_identity::transport::PROPOSAL_ALPN, + krikos_identity::transport::CHECKPOINT_ALPN, + krikos_identity::transport::TRANSPARENCY_GOSSIP_ALPN, + krikos_identity::transport::RECOVERY_ALPN, + ] { + assert!(application.registered_alpns().any(|alpn| alpn == expected)); + } + assert_eq!(application.metrics().protocol_count(), 9); + application.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn account_component_does_not_rewrite_endpoint_identity_persistence() { + let directory = tempfile::tempdir().unwrap(); + let root = DataRoot::open(directory.path()).unwrap(); + let identity_path = root.identity_path(); + drop(root); + + let with_account_identity = StandardBundle::persistent(directory.path()) + .local_only() + .identity_protocols(component(8)) + .start() + .await + .unwrap(); + let endpoint_id = with_account_identity.endpoint_id(); + with_account_identity.shutdown().await.unwrap(); + drop(with_account_identity); + let before = std::fs::read(&identity_path).unwrap(); + + let without_account_identity = StandardBundle::persistent(directory.path()) + .local_only() + .start() + .await + .unwrap(); + assert_eq!(without_account_identity.endpoint_id(), endpoint_id); + without_account_identity.shutdown().await.unwrap(); + drop(without_account_identity); + let after = std::fs::read(identity_path).unwrap(); + assert_eq!(after, before); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index cb3057ff837..4ceb9a2faa7 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -12,6 +12,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -19,7 +29,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -29,9 +39,9 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", + "aead 0.5.2", "aes", - "cipher", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -132,6 +142,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -402,6 +424,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.5" @@ -501,8 +532,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20", + "cipher 0.5.2", + "poly1305", + "zeroize", ] [[package]] @@ -526,7 +572,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -746,7 +803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -755,7 +812,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -778,7 +835,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto", - "rand_core", + "rand_core 0.10.1", "rustc_version", "serde", "subtle", @@ -890,6 +947,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -991,7 +1049,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.10.1", "serde", "sha2 0.11.0", "signature", @@ -1348,7 +1406,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1800,6 +1858,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inplace-vec-builder" version = "0.1.1" @@ -2246,15 +2313,19 @@ dependencies = [ name = "krikos-fuzz" version = "0.0.0" dependencies = [ + "futures-lite", "krikos-app", "krikos-base", "krikos-blobs", "krikos-dns-server", "krikos-docs", "krikos-gossip", + "krikos-identity", "krikos-relay", "libfuzzer-sys", "postcard", + "rand_core 0.10.1", + "tempfile", ] [[package]] @@ -2308,6 +2379,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "krikos-identity" +version = "1.0.0" +dependencies = [ + "argon2", + "blake3", + "chacha20poly1305", + "curve25519-dalek", + "data-encoding", + "krikos", + "krikos-base", + "postcard", + "rand_core 0.10.1", + "redb 4.1.0", + "serde", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "x25519-dalek", + "zeroize", +] + [[package]] name = "krikos-noq" version = "1.1.0-holon.1" @@ -3093,6 +3186,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3183,6 +3287,16 @@ dependencies = [ "time", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3192,7 +3306,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -3356,7 +3470,7 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3366,9 +3480,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" @@ -3381,7 +3501,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3947,7 +4067,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -4622,6 +4742,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5163,6 +5293,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.18.1" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index b8b2a786754..2e0adb459e4 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -13,11 +13,15 @@ krikos-dns-server = { path = "../krikos-dns-server", default-features = false, f krikos-relay = { path = "../krikos-relay", default-features = false, features = ["fuzzing"] } krikos-base = { path = "../krikos-base", default-features = false, features = ["key"] } krikos-gossip = { path = "../protocols/krikos-gossip", default-features = false } +krikos-identity = { path = "../protocols/krikos-identity", default-features = false, features = ["net", "provider-store"] } krikos-app = { path = "../framework/app", features = ["fuzzing"] } krikos-blobs = { path = "../protocols/krikos-blobs", default-features = false } krikos-docs = { path = "../protocols/krikos-docs", default-features = false } libfuzzer-sys = "0.4" postcard = { version = "1", default-features = false, features = ["alloc"] } +tempfile = "3.20" +futures-lite = "2" +rand_core = { version = "0.10", default-features = false } [workspace] members = ["."] @@ -89,3 +93,66 @@ path = "fuzz_targets/doc_ticket.rs" test = false doc = false bench = false + +[[bin]] +name = "identity_foundation" +path = "fuzz_targets/identity_foundation.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_schema" +path = "fuzz_targets/identity_schema.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_capability" +path = "fuzz_targets/identity_capability.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_merkle" +path = "fuzz_targets/identity_merkle.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_state" +path = "fuzz_targets/identity_state.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_pairing" +path = "fuzz_targets/identity_pairing.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_sync" +path = "fuzz_targets/identity_sync.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_provider" +path = "fuzz_targets/identity_provider.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "identity_semantics" +path = "fuzz_targets/identity_semantics.rs" +test = false +doc = false +bench = false diff --git a/fuzz/corpus/identity_capability/adversarial-context b/fuzz/corpus/identity_capability/adversarial-context new file mode 100644 index 00000000000..28a10277c8f --- /dev/null +++ b/fuzz/corpus/identity_capability/adversarial-context @@ -0,0 +1 @@ +0010100000001000000000 diff --git a/fuzz/corpus/identity_capability/context-time-backdating b/fuzz/corpus/identity_capability/context-time-backdating new file mode 100644 index 00000000000..2471d5d764b --- /dev/null +++ b/fuzz/corpus/identity_capability/context-time-backdating @@ -0,0 +1 @@ +0000000000001000000100 diff --git a/fuzz/corpus/identity_capability/issuance-rollback b/fuzz/corpus/identity_capability/issuance-rollback new file mode 100644 index 00000000000..9a227c7b4d2 --- /dev/null +++ b/fuzz/corpus/identity_capability/issuance-rollback @@ -0,0 +1 @@ +1000000000001000000010 diff --git a/fuzz/corpus/identity_capability/oversized-state-view b/fuzz/corpus/identity_capability/oversized-state-view new file mode 100644 index 00000000000..2c3e32e9c0a --- /dev/null +++ b/fuzz/corpus/identity_capability/oversized-state-view @@ -0,0 +1 @@ +0000000000001000000001 diff --git a/fuzz/corpus/identity_capability/revoked-delegated b/fuzz/corpus/identity_capability/revoked-delegated new file mode 100644 index 00000000000..3a77a466d12 --- /dev/null +++ b/fuzz/corpus/identity_capability/revoked-delegated @@ -0,0 +1 @@ +0000000000011000000000 diff --git a/fuzz/corpus/identity_capability/valid-delegated b/fuzz/corpus/identity_capability/valid-delegated new file mode 100644 index 00000000000..a9055e2f68a --- /dev/null +++ b/fuzz/corpus/identity_capability/valid-delegated @@ -0,0 +1 @@ +0000000000001000000000 diff --git a/fuzz/corpus/identity_foundation/seed.txt b/fuzz/corpus/identity_foundation/seed.txt new file mode 100644 index 00000000000..ae5beba00c8 --- /dev/null +++ b/fuzz/corpus/identity_foundation/seed.txt @@ -0,0 +1 @@ +KRIKOS-ID/foundation/v1 diff --git a/fuzz/corpus/identity_merkle/seed b/fuzz/corpus/identity_merkle/seed new file mode 100644 index 00000000000..f9a25fec394 --- /dev/null +++ b/fuzz/corpus/identity_merkle/seed @@ -0,0 +1 @@ +merkle-proof-v1 diff --git a/fuzz/corpus/identity_pairing/seed.txt b/fuzz/corpus/identity_pairing/seed.txt new file mode 100644 index 00000000000..b0541857657 --- /dev/null +++ b/fuzz/corpus/identity_pairing/seed.txt @@ -0,0 +1 @@ +0hex:01010101010101010101010101010101010101010101010101010101010101010101010143a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c0173b2d8b76aa9b53660032bc8f5d8bee3a3ae4e3b3a7fd49ade81f7347a34aa68010b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d0001343b62f7a40db173198b2d5d3ff1df419169d8e27f50f0f7b8845d993abdff0a01b0d08f35b4683381489afb32825e59152d47d19bc9e050d6d5a954984c9d1e2c010b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d1572656c61792e6578616d706c652e696e76616c6964014b2319918aa3b10e598e85505e5062aa4c65babbf3d4c3eaf544c3ddb1ef090ae807a8d7245a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a00 diff --git a/fuzz/corpus/identity_pairing/selector-1-pairing-transcript b/fuzz/corpus/identity_pairing/selector-1-pairing-transcript new file mode 100644 index 00000000000..7a27fe461e7 --- /dev/null +++ b/fuzz/corpus/identity_pairing/selector-1-pairing-transcript @@ -0,0 +1 @@ +1hex:0101835f0016f42c6ba397fe992b3df1ef11c8567903554ce56a249dfc9869b8d4fc014b2319918aa3b10e598e85505e5062aa4c65babbf3d4c3eaf544c3ddb1ef090a010101010101010101010101010101010101010101010101010101010101010101010143a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c0173b2d8b76aa9b53660032bc8f5d8bee3a3ae4e3b3a7fd49ade81f7347a34aa68010b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d0001343b62f7a40db173198b2d5d3ff1df419169d8e27f50f0f7b8845d993abdff0a010120828bf5c5bdcacb684863336c202fb5599da48be5596615742170705beca9f701bce059bf5b2ab7a91f3e863acf0c84d3ebbe04ca8490094b052b5b15afab174301511c34a1a2cb521df16bb246b8de8e7997ce235c7e76b22a3d7503a24819dd8a00014efdb831c79ca403b4b033adbd8073d269de1dd2a8c8b5b6ececdc326ad6416d010b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d01511c34a1a2cb521df16bb246b8de8e7997ce235c7e76b22a3d7503a24819dd8a41414141414141414141414141414141414141414141414141414141414141413131313131313131313131313131313131313131313131313131313131313131013be1436500dd6bad528b8bccd680eb2584a3bea25587ae39e6ce9dc8fb89ca8e01b0d08f35b4683381489afb32825e59152d47d19bc9e050d6d5a954984c9d1e2c01ad908a8a708aca07588cda7c4ed3e44d4966a80a9abb2f1e4bbac53c67414e34 diff --git a/fuzz/corpus/identity_pairing/selector-2-pairing-proof b/fuzz/corpus/identity_pairing/selector-2-pairing-proof new file mode 100644 index 00000000000..4654dc4fea7 --- /dev/null +++ b/fuzz/corpus/identity_pairing/selector-2-pairing-proof @@ -0,0 +1,15 @@ +2hex: +0101be8464d3d9b3be6e8e1e8519d681477f288011c2b1d1e821a0a522d241eb8ece +5a5a5a5a5a5a5a5a +5a5a5a5a5a5a5a5a +5a5a5a5a5a5a5a5a +5a5a5a5a5a5a5a5a +01 +a49e9f7af3512b7e99bb5e6b217bca0d18d3dcdc794a4e2280be8d496333e989 +ef1fca1d65360efd9e47e2e0c6aac575c0d6e8a38714251b417af3d504eed405 +01 +7887466073de189a66d7df665dcb7afe29612a40a1d2e71775144d8b250d6 +8e33e59c7099606344d7999260206be714669afd0a5117bc7c78c1d53ac44267c09 +44c0a714bbab7a0780221e855b7c36ddb9b1f00810927e0f45dada79504fa97b +001ce179516673b9c840f7e7f8a78d17a79b0ac9dd169872e1a28712696e4f2e +00 diff --git a/fuzz/corpus/identity_pairing/selector-3-device-authorization-proposal b/fuzz/corpus/identity_pairing/selector-3-device-authorization-proposal new file mode 100644 index 00000000000..ff73b26c56d --- /dev/null +++ b/fuzz/corpus/identity_pairing/selector-3-device-authorization-proposal @@ -0,0 +1 @@ +3hex:01010101010101010101010101010101010101010101010101010101010101010101010143a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c0173b2d8b76aa9b53660032bc8f5d8bee3a3ae4e3b3a7fd49ade81f7347a34aa68010b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d0001343b62f7a40db173198b2d5d3ff1df419169d8e27f50f0f7b8845d993abdff0a01835f0016f42c6ba397fe992b3df1ef11c8567903554ce56a249dfc9869b8d4fc01be8464d3d9b3be6e8e1e8519d681477f288011c2b1d1e821a0a522d241eb8ece01971ccac0d295a6c579e0785efc0224843af1f09cb5a4a56eb4ffd400f36ad91801be8464d3d9b3be6e8e1e8519d681477f288011c2b1d1e821a0a522d241eb8ece353830313538b817b91700 diff --git a/fuzz/corpus/identity_pairing/selector-4-presence-challenge b/fuzz/corpus/identity_pairing/selector-4-presence-challenge new file mode 100644 index 00000000000..8efc8c8fc41 --- /dev/null +++ b/fuzz/corpus/identity_pairing/selector-4-presence-challenge @@ -0,0 +1 @@ +4hex:0101010101010101010101010101010101010101010101010101010101010101010101343b62f7a40db173198b2d5d3ff1df419169d8e27f50f0f7b8845d993abdff0a31313131313131313131313131313131313131313131313131313131313131314141414141414141414141414141414141414141414141414141414141414141015151515151515151515151515151515151515151515151515151515151515151010202020202020202020202020202020202020202020202020202020202020202e807c8af120143a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c00 diff --git a/fuzz/corpus/identity_pairing/selector-5-presence-proof b/fuzz/corpus/identity_pairing/selector-5-presence-proof new file mode 100644 index 00000000000..6de856dea67 --- /dev/null +++ b/fuzz/corpus/identity_pairing/selector-5-presence-proof @@ -0,0 +1 @@ +5hex:0101010101010101010101010101010101010101010101010101010101010101010101343b62f7a40db173198b2d5d3ff1df419169d8e27f50f0f7b8845d993abdff0a31313131313131313131313131313131313131313131313131313131313131314141414141414141414141414141414141414141414141414141414141414141015151515151515151515151515151515151515151515151515151515151515151010202020202020202020202020202020202020202020202020202020202020202e807c8af120143a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c00015fb96b07b0867bd83e5674d136f3c6a04344f37589c82033771fef248fd26edbe6ad61318b1f29d9ac87fe047c2ce3ce0c47c8bb009fa6d5277b6bff5ba62601 diff --git a/fuzz/corpus/identity_provider/admission b/fuzz/corpus/identity_provider/admission new file mode 100644 index 00000000000..68e19f1348b --- /dev/null +++ b/fuzz/corpus/identity_provider/admission @@ -0,0 +1 @@ +6exact-admission-byte-charge diff --git a/fuzz/corpus/identity_provider/compaction b/fuzz/corpus/identity_provider/compaction new file mode 100644 index 00000000000..de0ad24ac02 --- /dev/null +++ b/fuzz/corpus/identity_provider/compaction @@ -0,0 +1 @@ +2mirror-compaction-manifest diff --git a/fuzz/corpus/identity_provider/corrupt b/fuzz/corpus/identity_provider/corrupt new file mode 100644 index 00000000000..5f2af356663 --- /dev/null +++ b/fuzz/corpus/identity_provider/corrupt @@ -0,0 +1 @@ +3mutate-committed-provider-bytes diff --git a/fuzz/corpus/identity_provider/generation b/fuzz/corpus/identity_provider/generation new file mode 100644 index 00000000000..2fd5ab5b750 --- /dev/null +++ b/fuzz/corpus/identity_provider/generation @@ -0,0 +1 @@ +5wrong-provider-log-generation diff --git a/fuzz/corpus/identity_provider/proofs b/fuzz/corpus/identity_provider/proofs new file mode 100644 index 00000000000..21ea63b352a --- /dev/null +++ b/fuzz/corpus/identity_provider/proofs @@ -0,0 +1 @@ +1duplicate-and-prefix-proofs diff --git a/fuzz/corpus/identity_provider/reopen b/fuzz/corpus/identity_provider/reopen new file mode 100644 index 00000000000..c53f992f967 --- /dev/null +++ b/fuzz/corpus/identity_provider/reopen @@ -0,0 +1 @@ +0reopen-authenticated-generation diff --git a/fuzz/corpus/identity_provider/selector-07-provider-export-component-accepted.bin b/fuzz/corpus/identity_provider/selector-07-provider-export-component-accepted.bin new file mode 100644 index 00000000000..b17a928721c --- /dev/null +++ b/fuzz/corpus/identity_provider/selector-07-provider-export-component-accepted.bin @@ -0,0 +1 @@ +7 \ No newline at end of file diff --git a/fuzz/corpus/identity_provider/selector-07-provider-export-component-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-07-provider-export-component-malformed-truncated.bin new file mode 100644 index 00000000000..a6f3fcf34fd --- /dev/null +++ b/fuzz/corpus/identity_provider/selector-07-provider-export-component-malformed-truncated.bin @@ -0,0 +1 @@ +7 \ No newline at end of file diff --git a/fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-accepted.bin b/fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-accepted.bin new file mode 100644 index 00000000000..e9670d4bca4 --- /dev/null +++ b/fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-accepted.bin @@ -0,0 +1 @@ +8ëLuNS:C"KzG+]hn5 \ No newline at end of file diff --git a/fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-malformed-truncated.bin new file mode 100644 index 00000000000..43f5933af17 --- /dev/null +++ b/fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-malformed-truncated.bin @@ -0,0 +1 @@ +8ëLuNS:C"KzG+]hn \ No newline at end of file diff --git a/fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-accepted.bin b/fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-accepted.bin new file mode 100644 index 00000000000..cb6614ba9ba Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-accepted.bin differ diff --git a/fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-malformed-truncated.bin new file mode 100644 index 00000000000..dc199f4735a Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-malformed-truncated.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-accepted.bin b/fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-accepted.bin new file mode 100644 index 00000000000..d98a3e371ea Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-accepted.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-malformed-truncated.bin new file mode 100644 index 00000000000..d287ccc7192 Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-malformed-truncated.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-accepted.bin b/fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-accepted.bin new file mode 100644 index 00000000000..142b1a86033 Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-accepted.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-malformed-truncated.bin new file mode 100644 index 00000000000..1301f2c6953 Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-malformed-truncated.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-accepted.bin b/fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-accepted.bin new file mode 100644 index 00000000000..a88352a95fe Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-accepted.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-malformed-truncated.bin new file mode 100644 index 00000000000..10242410931 Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-malformed-truncated.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-accepted.bin b/fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-accepted.bin new file mode 100644 index 00000000000..64c3011f186 Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-accepted.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-malformed-truncated.bin new file mode 100644 index 00000000000..40dd0988321 Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-malformed-truncated.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-accepted.bin b/fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-accepted.bin new file mode 100644 index 00000000000..5ad391d4c04 Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-accepted.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-malformed-truncated.bin new file mode 100644 index 00000000000..286123999cf Binary files /dev/null and b/fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-malformed-truncated.bin differ diff --git a/fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-accepted.bin b/fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-accepted.bin new file mode 100644 index 00000000000..065b498b3e1 --- /dev/null +++ b/fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-accepted.bin @@ -0,0 +1 @@ +f 2[h IîP8MV@>UdlB] \ No newline at end of file diff --git a/fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-malformed-truncated.bin b/fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-malformed-truncated.bin new file mode 100644 index 00000000000..597e591bfca --- /dev/null +++ b/fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-malformed-truncated.bin @@ -0,0 +1 @@ +f 2[h IîP8MV@>UdlB] \ No newline at end of file diff --git a/fuzz/corpus/identity_provider/truncate b/fuzz/corpus/identity_provider/truncate new file mode 100644 index 00000000000..b77d0a8cea7 --- /dev/null +++ b/fuzz/corpus/identity_provider/truncate @@ -0,0 +1 @@ +4truncate-committed-provider-file diff --git a/fuzz/corpus/identity_schema/admission-evidence-v1 b/fuzz/corpus/identity_schema/admission-evidence-v1 new file mode 100644 index 00000000000..573b269f5e3 Binary files /dev/null and b/fuzz/corpus/identity_schema/admission-evidence-v1 differ diff --git a/fuzz/corpus/identity_schema/resource-segment-33 b/fuzz/corpus/identity_schema/resource-segment-33 new file mode 100644 index 00000000000..b929deea81b --- /dev/null +++ b/fuzz/corpus/identity_schema/resource-segment-33 @@ -0,0 +1 @@ +%!01234567890123456789012345678901 diff --git a/fuzz/corpus/identity_schema/task8-name-seed b/fuzz/corpus/identity_schema/task8-name-seed new file mode 100644 index 00000000000..b1f116e5840 --- /dev/null +++ b/fuzz/corpus/identity_schema/task8-name-seed @@ -0,0 +1 @@ +bname diff --git a/fuzz/corpus/identity_schema/task8-private-seed b/fuzz/corpus/identity_schema/task8-private-seed new file mode 100644 index 00000000000..f3d048bbb06 --- /dev/null +++ b/fuzz/corpus/identity_schema/task8-private-seed @@ -0,0 +1 @@ +eprivate diff --git a/fuzz/corpus/identity_schema/task8-recovery-seed b/fuzz/corpus/identity_schema/task8-recovery-seed new file mode 100644 index 00000000000..346ea502134 --- /dev/null +++ b/fuzz/corpus/identity_schema/task8-recovery-seed @@ -0,0 +1 @@ +Precovery diff --git a/fuzz/corpus/identity_schema/task8-social-seed b/fuzz/corpus/identity_schema/task8-social-seed new file mode 100644 index 00000000000..11fe11461b3 --- /dev/null +++ b/fuzz/corpus/identity_schema/task8-social-seed @@ -0,0 +1 @@ +`social diff --git a/fuzz/corpus/identity_semantics/selector-53-guardian-rejected b/fuzz/corpus/identity_semantics/selector-53-guardian-rejected new file mode 100644 index 00000000000..31285055a44 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-53-guardian-rejected @@ -0,0 +1 @@ +5! diff --git a/fuzz/corpus/identity_semantics/selector-53-guardian-valid b/fuzz/corpus/identity_semantics/selector-53-guardian-valid new file mode 100644 index 00000000000..7ed6ff82de6 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-53-guardian-valid @@ -0,0 +1 @@ +5 diff --git a/fuzz/corpus/identity_semantics/selector-54-social-rejected b/fuzz/corpus/identity_semantics/selector-54-social-rejected new file mode 100644 index 00000000000..b88ceb83a9c --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-54-social-rejected @@ -0,0 +1 @@ +6! diff --git a/fuzz/corpus/identity_semantics/selector-54-social-valid b/fuzz/corpus/identity_semantics/selector-54-social-valid new file mode 100644 index 00000000000..1e8b3149621 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-54-social-valid @@ -0,0 +1 @@ +6 diff --git a/fuzz/corpus/identity_semantics/selector-55-name-rejected b/fuzz/corpus/identity_semantics/selector-55-name-rejected new file mode 100644 index 00000000000..8b8115051e8 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-55-name-rejected @@ -0,0 +1 @@ +7! diff --git a/fuzz/corpus/identity_semantics/selector-55-name-valid b/fuzz/corpus/identity_semantics/selector-55-name-valid new file mode 100644 index 00000000000..7f8f011eb73 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-55-name-valid @@ -0,0 +1 @@ +7 diff --git a/fuzz/corpus/identity_semantics/selector-56-private-metadata-rejected b/fuzz/corpus/identity_semantics/selector-56-private-metadata-rejected new file mode 100644 index 00000000000..1c4ea1f533e --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-56-private-metadata-rejected @@ -0,0 +1 @@ +8! diff --git a/fuzz/corpus/identity_semantics/selector-56-private-metadata-valid b/fuzz/corpus/identity_semantics/selector-56-private-metadata-valid new file mode 100644 index 00000000000..45a4fb75db8 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-56-private-metadata-valid @@ -0,0 +1 @@ +8 diff --git a/fuzz/corpus/identity_semantics/selector-57-portable-credential-rejected b/fuzz/corpus/identity_semantics/selector-57-portable-credential-rejected new file mode 100644 index 00000000000..043c463041f --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-57-portable-credential-rejected @@ -0,0 +1 @@ +9! diff --git a/fuzz/corpus/identity_semantics/selector-57-portable-credential-valid b/fuzz/corpus/identity_semantics/selector-57-portable-credential-valid new file mode 100644 index 00000000000..ec635144f60 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-57-portable-credential-valid @@ -0,0 +1 @@ +9 diff --git a/fuzz/corpus/identity_semantics/selector-58-application-rejected b/fuzz/corpus/identity_semantics/selector-58-application-rejected new file mode 100644 index 00000000000..39c0a8584fb --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-58-application-rejected @@ -0,0 +1 @@ +:! diff --git a/fuzz/corpus/identity_semantics/selector-58-application-valid b/fuzz/corpus/identity_semantics/selector-58-application-valid new file mode 100644 index 00000000000..397db75f0d9 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-58-application-valid @@ -0,0 +1 @@ +: diff --git a/fuzz/corpus/identity_semantics/selector-59-presence-rejected b/fuzz/corpus/identity_semantics/selector-59-presence-rejected new file mode 100644 index 00000000000..3b0f1e03d74 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-59-presence-rejected @@ -0,0 +1 @@ +;! diff --git a/fuzz/corpus/identity_semantics/selector-59-presence-valid b/fuzz/corpus/identity_semantics/selector-59-presence-valid new file mode 100644 index 00000000000..092bc2b0412 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-59-presence-valid @@ -0,0 +1 @@ +; diff --git a/fuzz/corpus/identity_semantics/selector-60-freshness-rejected b/fuzz/corpus/identity_semantics/selector-60-freshness-rejected new file mode 100644 index 00000000000..6cbd8c987e6 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-60-freshness-rejected @@ -0,0 +1 @@ +! diff --git a/fuzz/corpus/identity_semantics/selector-62-provider-retention-valid b/fuzz/corpus/identity_semantics/selector-62-provider-retention-valid new file mode 100644 index 00000000000..d9e80f6f760 --- /dev/null +++ b/fuzz/corpus/identity_semantics/selector-62-provider-retention-valid @@ -0,0 +1 @@ +> diff --git a/fuzz/corpus/identity_state/boundary b/fuzz/corpus/identity_state/boundary new file mode 100644 index 00000000000..8de8f435179 --- /dev/null +++ b/fuzz/corpus/identity_state/boundary @@ -0,0 +1 @@ + diff --git a/fuzz/corpus/identity_state/fork b/fuzz/corpus/identity_state/fork new file mode 100644 index 00000000000..360dface04c --- /dev/null +++ b/fuzz/corpus/identity_state/fork @@ -0,0 +1 @@ +fork-arrival-convergence diff --git a/fuzz/corpus/identity_state/linear b/fuzz/corpus/identity_state/linear new file mode 100644 index 00000000000..82521602b2e --- /dev/null +++ b/fuzz/corpus/identity_state/linear @@ -0,0 +1 @@ +linear-state-model diff --git a/fuzz/corpus/identity_sync/seed.txt b/fuzz/corpus/identity_sync/seed.txt new file mode 100644 index 00000000000..04f175f2a1c --- /dev/null +++ b/fuzz/corpus/identity_sync/seed.txt @@ -0,0 +1 @@ +sync-v1 diff --git a/fuzz/corpus/identity_sync/selector-00-sync-request-accepted.bin b/fuzz/corpus/identity_sync/selector-00-sync-request-accepted.bin new file mode 100644 index 00000000000..3ab1fa9fd42 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-00-sync-request-accepted.bin differ diff --git a/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-duplicate-head.bin b/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-duplicate-head.bin new file mode 100644 index 00000000000..4ebaf9364cc Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-duplicate-head.bin differ diff --git a/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-truncated.bin b/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-truncated.bin new file mode 100644 index 00000000000..eda993788e3 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-truncated.bin differ diff --git a/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-unsupported-version.bin b/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-unsupported-version.bin new file mode 100644 index 00000000000..8df5204286f Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-00-sync-request-rejected-unsupported-version.bin differ diff --git a/fuzz/corpus/identity_sync/selector-01-sync-frame-accepted.bin b/fuzz/corpus/identity_sync/selector-01-sync-frame-accepted.bin new file mode 100644 index 00000000000..3135eb4166c Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-01-sync-frame-accepted.bin differ diff --git a/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-duplicate-head.bin b/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-duplicate-head.bin new file mode 100644 index 00000000000..853a3f12fe1 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-duplicate-head.bin differ diff --git a/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-truncated.bin b/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-truncated.bin new file mode 100644 index 00000000000..e4c0e8ad809 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-truncated.bin differ diff --git a/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-unsupported-version.bin b/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-unsupported-version.bin new file mode 100644 index 00000000000..864fced3c76 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-unsupported-version.bin differ diff --git a/fuzz/corpus/identity_sync/selector-02-sync-cursor-accepted.bin b/fuzz/corpus/identity_sync/selector-02-sync-cursor-accepted.bin new file mode 100644 index 00000000000..4b4a8d98652 --- /dev/null +++ b/fuzz/corpus/identity_sync/selector-02-sync-cursor-accepted.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MUse _r}Oiku5V\x6:wZIf F[G-A:7ȝ3 \ No newline at end of file diff --git a/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-duplicate-head.bin b/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-duplicate-head.bin new file mode 100644 index 00000000000..2b9bd6673a9 --- /dev/null +++ b/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-duplicate-head.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MUse _r}Oiku5V\x6se _r}Oiku5V\x6:wZIf F[G-A:7ȝ3 \ No newline at end of file diff --git a/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-truncated.bin b/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-truncated.bin new file mode 100644 index 00000000000..b9dba8c0ec6 --- /dev/null +++ b/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-truncated.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MUse _r}Oiku5V\x6:wZIf F[G-A:7ȝ \ No newline at end of file diff --git a/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-unsupported-version.bin b/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-unsupported-version.bin new file mode 100644 index 00000000000..7429af96e4d --- /dev/null +++ b/fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-unsupported-version.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MUse _r}Oiku5V\x6:wZIf F[G-A:7ȝ3 \ No newline at end of file diff --git a/fuzz/corpus/identity_sync/selector-03-sync-response-accepted.bin b/fuzz/corpus/identity_sync/selector-03-sync-response-accepted.bin new file mode 100644 index 00000000000..106da154dbf Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-03-sync-response-accepted.bin differ diff --git a/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-legacy-ordinal.bin b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-legacy-ordinal.bin new file mode 100644 index 00000000000..a8150186a06 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-legacy-ordinal.bin differ diff --git a/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-truncated.bin b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-truncated.bin new file mode 100644 index 00000000000..ebf6ea53a5a Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-truncated.bin differ diff --git a/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-codepoint.bin b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-codepoint.bin new file mode 100644 index 00000000000..720714fbf48 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-codepoint.bin differ diff --git a/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-version.bin b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-version.bin new file mode 100644 index 00000000000..1cedd392042 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-version.bin differ diff --git a/fuzz/corpus/identity_sync/selector-04-endpoint-authorization-accepted.bin b/fuzz/corpus/identity_sync/selector-04-endpoint-authorization-accepted.bin new file mode 100644 index 00000000000..c4540d00d37 --- /dev/null +++ b/fuzz/corpus/identity_sync/selector-04-endpoint-authorization-accepted.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MU:At?5ZS*{c |@~t)!Vu \ No newline at end of file diff --git a/fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-accepted.bin b/fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-accepted.bin new file mode 100644 index 00000000000..b9a9ebc3609 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-accepted.bin differ diff --git a/fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-rejected-truncated.bin b/fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-rejected-truncated.bin new file mode 100644 index 00000000000..becbf90d044 Binary files /dev/null and b/fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-rejected-truncated.bin differ diff --git a/fuzz/fuzz_targets/identity_capability.rs b/fuzz/fuzz_targets/identity_capability.rs new file mode 100644 index 00000000000..f9b067a1b2b --- /dev/null +++ b/fuzz/fuzz_targets/identity_capability.rs @@ -0,0 +1,583 @@ +#![no_main] + +use krikos_identity::{ + AccountId, ApplicationId, AuthorizationContext, CanonicalWire, CapabilityAction, + CapabilityDenialReason, CapabilityDeviceStatus, CapabilityGrant, CapabilityGrantId, + CapabilityNamespace, CapabilityProof, CapabilityRequest, CapabilityStateView, CheckpointId, + DelegationBody, DelegationChain, DelegationDepth, DelegationId, DelegationPermission, + DelegationSignatureStatus, DelegationSignatureVerifier, DeviceId, Digest, Epoch, Extensions, + HashAlgorithm, ProtocolSignature, ResourcePath, ResourceSelector, SignedDelegation, Timestamp, + evaluate_capability, + limits::{MAX_CAPABILITIES_PER_DEVICE, MAX_DELEGATION_DEPTH}, +}; +use libfuzzer_sys::fuzz_target; + +const MAX_FUZZ_INPUT_BYTES: usize = 64; + +#[derive(Debug, Default, Clone, Copy)] +struct InjectedFaults { + invalid_signature: bool, + revoked_authority: bool, + missing_possession: bool, + inactive_authority: bool, + missing_lineage: bool, + sibling_context: bool, + missing_context_timestamp: bool, + context_time_backdating: bool, + issuance_time_rollback: bool, + future_issuance: bool, + request_basis_mismatch: bool, + scope_mismatch: bool, + request_after_expiry: bool, + oversized_state_view: bool, +} + +impl InjectedFaults { + fn requires_denial(self) -> bool { + self.invalid_signature + || self.revoked_authority + || self.missing_possession + || self.inactive_authority + || self.missing_lineage + || self.sibling_context + || self.missing_context_timestamp + || self.context_time_backdating + || self.issuance_time_rollback + || self.future_issuance + || self.request_basis_mismatch + || self.scope_mismatch + || self.request_after_expiry + || self.oversized_state_view + } +} + +#[derive(Debug)] +struct FuzzState { + authorization_context: AuthorizationContext, + statuses: Vec<(DeviceId, CapabilityDeviceStatus)>, + root_holder: DeviceId, + root_grants: Vec, + revoked_grants: Vec, + revoked_delegations: Vec, + recognized_contexts: Vec, + context_lineage: Vec<(AuthorizationContext, AuthorizationContext)>, + context_times: Vec<(AuthorizationContext, Timestamp)>, + historical_statuses: Vec<(DeviceId, AuthorizationContext, CapabilityDeviceStatus)>, + historical_holdings: Vec<(DeviceId, CapabilityGrantId, AuthorizationContext)>, +} + +impl CapabilityStateView for FuzzState { + fn authorization_context(&self) -> AuthorizationContext { + self.authorization_context + } + + fn device_status(&self, device_id: DeviceId) -> CapabilityDeviceStatus { + self.statuses + .iter() + .find_map(|(candidate, status)| (*candidate == device_id).then_some(*status)) + .unwrap_or(CapabilityDeviceStatus::Unknown) + } + + fn root_grants(&self, holder: DeviceId) -> &[CapabilityGrant] { + if holder == self.root_holder { + &self.root_grants + } else { + &[] + } + } + + fn is_grant_revoked(&self, grant_id: CapabilityGrantId) -> bool { + self.revoked_grants.contains(&grant_id) + } + + fn is_delegation_revoked(&self, delegation_id: DelegationId) -> bool { + self.revoked_delegations.contains(&delegation_id) + } + + fn recognizes_authorization_context(&self, context: AuthorizationContext) -> bool { + self.recognized_contexts.contains(&context) + } + + fn authorization_context_precedes_or_equals( + &self, + ancestor: AuthorizationContext, + descendant: AuthorizationContext, + ) -> bool { + ancestor.account_id() == descendant.account_id() + && self.recognized_contexts.contains(&ancestor) + && self.recognized_contexts.contains(&descendant) + && (ancestor == descendant || self.context_lineage.contains(&(ancestor, descendant))) + } + + fn authorization_context_timestamp(&self, context: AuthorizationContext) -> Option { + self.context_times + .iter() + .find_map(|(candidate, timestamp)| (*candidate == context).then_some(*timestamp)) + } + + fn device_status_at( + &self, + device_id: DeviceId, + context: AuthorizationContext, + ) -> CapabilityDeviceStatus { + self.historical_statuses + .iter() + .find_map(|(candidate, candidate_context, status)| { + (*candidate == device_id && *candidate_context == context).then_some(*status) + }) + .unwrap_or(CapabilityDeviceStatus::Unknown) + } + + fn held_grant_at( + &self, + holder: DeviceId, + grant_id: CapabilityGrantId, + context: AuthorizationContext, + ) -> bool { + self.historical_holdings + .contains(&(holder, grant_id, context)) + } +} + +#[derive(Debug, Clone, Copy)] +struct FuzzSignatures(DelegationSignatureStatus); + +impl DelegationSignatureVerifier for FuzzSignatures { + fn verify_delegation(&self, _delegation: &SignedDelegation) -> DelegationSignatureStatus { + self.0 + } +} + +fn byte(input: &[u8], index: usize) -> u8 { + input.get(index).copied().unwrap_or(0) +} + +fn digest(seed: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [seed; 32]) +} + +fn account_id(seed: u8) -> Option { + let encoded = digest(seed).to_canonical_bytes().ok()?; + AccountId::from_canonical_bytes(&encoded).ok() +} + +fn checkpoint_id(seed: u8) -> Option { + let encoded = digest(seed).to_canonical_bytes().ok()?; + CheckpointId::from_canonical_bytes(&encoded).ok() +} + +fn device_id(seed: u8) -> Option { + let encoded = digest(seed).to_canonical_bytes().ok()?; + DeviceId::from_canonical_bytes(&encoded).ok() +} + +fn context(account_id: AccountId, epoch: u64, checkpoint_seed: u8) -> Option { + Some(AuthorizationContext::new( + account_id, + Epoch::new(epoch), + checkpoint_id(checkpoint_seed)?, + )) +} + +fn replace_context_timestamp( + state: &mut FuzzState, + context: AuthorizationContext, + timestamp: Timestamp, +) { + if let Some((_, stored_timestamp)) = state + .context_times + .iter_mut() + .find(|(candidate, _)| *candidate == context) + { + *stored_timestamp = timestamp; + } +} + +fn replace_device_status( + state: &mut FuzzState, + device_id: DeviceId, + status: CapabilityDeviceStatus, +) { + if let Some((_, stored_status)) = state + .statuses + .iter_mut() + .find(|(candidate, _)| *candidate == device_id) + { + *stored_status = status; + } +} + +fn drive(input: &[u8]) -> Option<()> { + let account_id = account_id(1)?; + let application_id = ApplicationId::new(digest(2)); + let root_holder = device_id(10)?; + let root_context = context(account_id, 1, 1)?; + let current_context = context(account_id, 64, 64)?; + let depth = 1_usize.checked_add(usize::from(byte(input, 0)) % MAX_DELEGATION_DEPTH)?; + let root_depth = DelegationDepth::new(u8::try_from(depth).ok()?).ok()?; + let namespace = CapabilityNamespace::new("krikos.database").ok()?; + let action = CapabilityAction::new("write").ok()?; + let mut segments = vec![b"collection".to_vec()]; + let root_grant = CapabilityGrant::new( + namespace.clone(), + action.clone(), + ResourceSelector::prefix(ResourcePath::new(segments.clone()).ok()?).ok()?, + vec![ + krikos_identity::CapabilityConstraint::AccountEpochAtLeast(Epoch::new(1)), + krikos_identity::CapabilityConstraint::AccountEpochAtMost(Epoch::new(64)), + krikos_identity::CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(0)), + ], + DelegationPermission::delegable(root_depth), + Some(Timestamp::from_unix_millis(1_000)), + Extensions::default(), + ) + .ok()?; + let root = krikos_identity::CapabilityRoot::new( + root_context, + root_holder, + root_grant.clone(), + Extensions::default(), + ) + .ok()?; + + let mut links = Vec::with_capacity(depth); + let mut grant_ids = vec![root_grant.capability_grant_id().ok()?]; + let mut delegation_ids = Vec::with_capacity(depth); + let mut contexts = vec![root_context]; + let mut holders = vec![root_holder]; + let mut parent = root_grant.clone(); + let mut issuer = root_holder; + let mut previous_context = root_context; + let mut previous_issued_at = Timestamp::from_unix_millis(0); + let context_fault_index = usize::from(byte(input, 1)) % depth; + let same_epoch_context_requested = byte(input, 2) & 1 != 0; + let future_issuance_requested = byte(input, 3) & 1 != 0; + let missing_lineage_requested = byte(input, 4) & 1 != 0; + let issuance_rollback_index = if depth > 1 && byte(input, 20) & 1 != 0 { + Some(1_usize.checked_add(usize::from(byte(input, 1)) % depth.checked_sub(1)?)?) + } else { + None + }; + let mut issuance_rollback_injected = false; + + for index in 0..depth { + segments.push(vec![u8::try_from(index).ok()?]); + let remaining = depth.checked_sub(index)?.checked_sub(1)?; + let delegation = if remaining == 0 { + DelegationPermission::NotDelegable + } else { + DelegationPermission::delegable( + DelegationDepth::new(u8::try_from(remaining).ok()?).ok()?, + ) + }; + let selector = if remaining == 0 { + ResourceSelector::exact(ResourcePath::new(segments.clone()).ok()?).ok()? + } else { + ResourceSelector::prefix(ResourcePath::new(segments.clone()).ok()?).ok()? + }; + let ordinal = u64::try_from(index).ok()?.checked_add(1)?; + let child = CapabilityGrant::new( + namespace.clone(), + action.clone(), + selector, + vec![ + krikos_identity::CapabilityConstraint::AccountEpochAtLeast(Epoch::new(1)), + krikos_identity::CapabilityConstraint::AccountEpochAtMost(Epoch::new(64)), + krikos_identity::CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(0)), + ], + delegation, + Some(Timestamp::from_unix_millis(1_000_u64.checked_sub(ordinal)?)), + Extensions::default(), + ) + .ok()?; + let normal_epoch = previous_context.epoch().get().checked_add(1)?; + let link_context = if index == context_fault_index && same_epoch_context_requested { + context( + account_id, + previous_context.epoch().get(), + u8::try_from(index.checked_add(100)?).ok()?, + )? + } else { + context( + account_id, + normal_epoch, + u8::try_from(index.checked_add(2)?).ok()?, + )? + }; + let subject = device_id(u8::try_from(index.checked_add(11)?).ok()?)?; + let future_issuance = index == context_fault_index && future_issuance_requested; + let issuance_rollback = issuance_rollback_index == Some(index) && !future_issuance; + let issued_at = if future_issuance { + Timestamp::from_unix_millis(2_000) + } else if issuance_rollback { + issuance_rollback_injected = true; + Timestamp::from_unix_millis(previous_issued_at.as_unix_millis().checked_sub(1)?) + } else { + Timestamp::from_unix_millis(10_u64.checked_add(ordinal)?) + }; + let body = DelegationBody::new( + parent.capability_grant_id().ok()?, + child.clone(), + issuer, + subject, + link_context, + issued_at, + [u8::try_from(index).ok()?; 16], + Extensions::default(), + ) + .ok()?; + let link = SignedDelegation::new( + body, + ProtocolSignature::ed25519([u8::try_from(index).ok()?; 64]), + ); + grant_ids.push(child.capability_grant_id().ok()?); + delegation_ids.push(link.delegation_id().ok()?); + contexts.push(link_context); + holders.push(subject); + links.push(link); + parent = child; + issuer = subject; + previous_context = link_context; + previous_issued_at = issued_at; + } + let chain = DelegationChain::new(root, links).ok()?; + + let mut state = FuzzState { + authorization_context: current_context, + statuses: holders + .iter() + .copied() + .map(|holder| (holder, CapabilityDeviceStatus::Active)) + .collect(), + root_holder, + root_grants: vec![root_grant.clone()], + revoked_grants: Vec::new(), + revoked_delegations: Vec::new(), + recognized_contexts: contexts.clone(), + context_lineage: Vec::new(), + context_times: Vec::new(), + historical_statuses: Vec::new(), + historical_holdings: Vec::new(), + }; + state.recognized_contexts.push(current_context); + for (index, context) in contexts.iter().enumerate() { + state.context_lineage.push((*context, current_context)); + let timestamp = if index == 0 { + Timestamp::from_unix_millis(0) + } else { + chain.links().get(index.checked_sub(1)?)?.body().issued_at() + }; + state.context_times.push((*context, timestamp)); + } + for index in 0..depth { + let ancestor = *contexts.get(index)?; + let descendant = *contexts.get(index.checked_add(1)?)?; + if index != context_fault_index || !missing_lineage_requested { + state.context_lineage.push((ancestor, descendant)); + } + let holder = *holders.get(index)?; + let status = if index == context_fault_index && byte(input, 5) & 1 != 0 { + CapabilityDeviceStatus::Suspended + } else { + CapabilityDeviceStatus::Active + }; + state.historical_statuses.push((holder, descendant, status)); + if index != context_fault_index || byte(input, 6) & 1 == 0 { + state + .historical_holdings + .push((holder, *grant_ids.get(index)?, descendant)); + } + } + let root_status = if byte(input, 7) & 1 == 0 { + CapabilityDeviceStatus::Active + } else { + CapabilityDeviceStatus::Suspended + }; + state + .historical_statuses + .push((root_holder, root_context, root_status)); + if byte(input, 8) & 1 == 0 { + state + .historical_holdings + .push((root_holder, *grant_ids.first()?, root_context)); + } + let root_timestamp_missing = byte(input, 9) & 1 != 0; + let root_issuance_rollback = !root_timestamp_missing && byte(input, 9) & 2 != 0; + if root_timestamp_missing { + state + .context_times + .retain(|(context, _)| *context != root_context); + } else if root_issuance_rollback { + let first_issued_at = chain.links().first()?.body().issued_at(); + replace_context_timestamp( + &mut state, + root_context, + Timestamp::from_unix_millis(first_issued_at.as_unix_millis().checked_add(1)?), + ); + } + + let selected_link = chain.links().get(context_fault_index)?; + let selected_link_context = selected_link.body().authorization_context(); + let link_timestamp_missing = byte(input, 18) & 1 != 0; + let context_time_backdating = !link_timestamp_missing && byte(input, 19) & 1 != 0; + if link_timestamp_missing { + state + .context_times + .retain(|(context, _)| *context != selected_link_context); + } else if context_time_backdating { + replace_context_timestamp( + &mut state, + selected_link_context, + Timestamp::from_unix_millis( + selected_link + .body() + .issued_at() + .as_unix_millis() + .checked_add(1)?, + ), + ); + } + + let oversized_state_view = byte(input, 21) & 1 != 0; + if oversized_state_view { + state + .root_grants + .resize(MAX_CAPABILITIES_PER_DEVICE.checked_add(1)?, root_grant); + } + + let direct = byte(input, 12) & 1 == 0; + let revocation_index = usize::from(byte(input, 10)); + let mut revoked_authority = false; + if byte(input, 11) & 1 != 0 { + if byte(input, 11) & 2 == 0 { + let selected_index = revocation_index % grant_ids.len(); + state.revoked_grants.push(*grant_ids.get(selected_index)?); + revoked_authority = !direct || selected_index == 0; + } else { + state + .revoked_delegations + .push(*delegation_ids.get(revocation_index % delegation_ids.len())?); + revoked_authority = !direct; + } + } + + let requesting_device = if direct { + root_holder + } else { + chain.leaf_holder() + }; + if byte(input, 13) & 1 != 0 { + let status = if byte(input, 13) & 2 == 0 { + CapabilityDeviceStatus::Suspended + } else { + CapabilityDeviceStatus::Revoked + }; + replace_device_status(&mut state, requesting_device, status); + } + let request_context = if byte(input, 14) & 1 != 0 { + context(account_id, 63, 63)? + } else if byte(input, 14) & 2 != 0 { + context(account_id, 64, 63)? + } else { + current_context + }; + let request_namespace = if byte(input, 15) & 1 == 0 { + namespace + } else { + CapabilityNamespace::new("krikos.other").ok()? + }; + let request_action = if byte(input, 15) & 2 == 0 { + action + } else { + CapabilityAction::new("read").ok()? + }; + let request_path = if byte(input, 15) & 4 == 0 { + if direct { + ResourcePath::new(vec![b"collection".to_vec()]).ok()? + } else { + ResourcePath::new(segments).ok()? + } + } else { + ResourcePath::new(vec![b"other".to_vec()]).ok()? + }; + let evaluated_at = if byte(input, 16) & 1 == 0 { + Timestamp::from_unix_millis(500) + } else { + Timestamp::from_unix_millis(2_000) + }; + let request = CapabilityRequest::new( + request_context, + application_id, + requesting_device, + request_namespace, + request_action, + request_path, + evaluated_at, + ); + let signature_status = match byte(input, 17) % 3 { + 0 => DelegationSignatureStatus::Verified, + 1 => DelegationSignatureStatus::Invalid, + _ => DelegationSignatureStatus::Unavailable, + }; + let delegated = !direct; + let faults = InjectedFaults { + invalid_signature: delegated && signature_status != DelegationSignatureStatus::Verified, + revoked_authority, + missing_possession: delegated && (byte(input, 6) & 1 != 0 || byte(input, 8) & 1 != 0), + inactive_authority: byte(input, 13) & 1 != 0 + || (delegated && (byte(input, 5) & 1 != 0 || byte(input, 7) & 1 != 0)), + missing_lineage: delegated && missing_lineage_requested, + sibling_context: delegated && same_epoch_context_requested && missing_lineage_requested, + missing_context_timestamp: delegated && (root_timestamp_missing || link_timestamp_missing), + context_time_backdating: delegated && context_time_backdating, + issuance_time_rollback: delegated && (root_issuance_rollback || issuance_rollback_injected), + future_issuance: delegated && future_issuance_requested, + request_basis_mismatch: request_context != current_context, + scope_mismatch: byte(input, 15) & 7 != 0, + request_after_expiry: byte(input, 16) & 1 != 0, + oversized_state_view, + }; + let proof = if direct { + CapabilityProof::Direct + } else { + CapabilityProof::Delegated(&chain) + }; + let decision = evaluate_capability(&request, proof, &state, &FuzzSignatures(signature_status)); + assert_eq!(decision.checkpoint_id(), request_context.checkpoint_id()); + assert_eq!(decision.epoch(), request_context.epoch()); + assert_eq!(decision.is_allowed(), decision.denial_reason().is_none()); + if oversized_state_view + && request_context == current_context + && state.device_status(requesting_device) == CapabilityDeviceStatus::Active + { + assert_eq!( + decision.denial_reason(), + Some(CapabilityDenialReason::StateViewLimitExceeded), + "over-limit root grant slice must fail at the evaluator bound" + ); + } + if faults.requires_denial() { + assert!( + !decision.is_allowed(), + "fault-injected capability proof was authorized: {faults:?}" + ); + } + if delegated && !faults.requires_denial() { + assert!( + decision.is_allowed(), + "pristine delegated capability proof was denied: {:?}", + decision.denial_reason() + ); + } + if decision.is_allowed() { + assert!(decision.grant_id().is_some()); + } + Some(()) +} + +fuzz_target!(|input: &[u8]| { + if input.len() > MAX_FUZZ_INPUT_BYTES { + return; + } + let _ = drive(input); +}); diff --git a/fuzz/fuzz_targets/identity_foundation.rs b/fuzz/fuzz_targets/identity_foundation.rs new file mode 100644 index 00000000000..348f9e0c3db --- /dev/null +++ b/fuzz/fuzz_targets/identity_foundation.rs @@ -0,0 +1,27 @@ +#![no_main] + +use krikos_identity::{ + AeadAlgorithm, AgreementAlgorithm, AgreementPublicKey, CanonicalWire, Digest, DurationMillis, + Epoch, Extensions, HashAlgorithm, KdfAlgorithm, OperationKind, ProtocolSignature, + ProtocolVersion, Sequence, SignatureAlgorithm, SigningPublicKey, Timestamp, +}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|input: &[u8]| { + let _ = HashAlgorithm::from_canonical_bytes(input); + let _ = SignatureAlgorithm::from_canonical_bytes(input); + let _ = AgreementAlgorithm::from_canonical_bytes(input); + let _ = KdfAlgorithm::from_canonical_bytes(input); + let _ = AeadAlgorithm::from_canonical_bytes(input); + let _ = OperationKind::from_canonical_bytes(input); + let _ = ProtocolVersion::from_canonical_bytes(input); + let _ = Epoch::from_canonical_bytes(input); + let _ = Sequence::from_canonical_bytes(input); + let _ = Timestamp::from_canonical_bytes(input); + let _ = DurationMillis::from_canonical_bytes(input); + let _ = Digest::from_canonical_bytes(input); + let _ = SigningPublicKey::from_canonical_bytes(input); + let _ = AgreementPublicKey::from_canonical_bytes(input); + let _ = ProtocolSignature::from_canonical_bytes(input); + let _ = Extensions::from_canonical_bytes(input); +}); diff --git a/fuzz/fuzz_targets/identity_merkle.rs b/fuzz/fuzz_targets/identity_merkle.rs new file mode 100644 index 00000000000..9649cc0306a --- /dev/null +++ b/fuzz/fuzz_targets/identity_merkle.rs @@ -0,0 +1,61 @@ +#![no_main] + +use krikos_identity::{ + CanonicalWire, CheckpointBody, InclusionReceipt, ProviderEquivocationEvidence, + ProviderHeadBody, ProviderLogEntryBody, ProviderReceipts, SignedCheckpoint, SignedProviderHead, + limits::MAX_ENCODED_OBJECT_BYTES, + merkle::{ + MerkleConsistencyProof, MerkleInclusionProof, MerkleNonMembershipProof, MerkleSetKey, + MerkleSetLeaf, + }, +}; +use libfuzzer_sys::fuzz_target; + +type Decoder = fn(&[u8]); + +const DECODERS: [Decoder; 13] = [ + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, +]; + +fn round_trip(payload: &[u8]) { + let Ok(decoded) = T::from_canonical_bytes(payload) else { + return; + }; + assert_eq!( + decoded.to_canonical_bytes().as_deref(), + Ok(payload), + "an accepted Merkle object failed canonical reproduction" + ); +} + +fuzz_target!(|input: &[u8]| { + let Some((&selector, payload)) = input.split_first() else { + return; + }; + if payload.len() > MAX_ENCODED_OBJECT_BYTES { + return; + } + // Raw selector values are append-only. `b'm'` is retained as an alias for the historical + // `merkle-proof-v1` corpus seed, which previously reached index five through `% 13`. + let decoder_index = if selector == b'm' { + 5 + } else { + usize::from(selector) + }; + let Some(decoder) = DECODERS.get(decoder_index) else { + return; + }; + decoder(payload); +}); diff --git a/fuzz/fuzz_targets/identity_pairing.rs b/fuzz/fuzz_targets/identity_pairing.rs new file mode 100644 index 00000000000..293869f2351 --- /dev/null +++ b/fuzz/fuzz_targets/identity_pairing.rs @@ -0,0 +1,87 @@ +#![no_main] + +use std::borrow::Cow; + +use krikos_identity::{ + CanonicalWire, DeviceAuthorizationProposal, DevicePresenceChallenge, PairingPossessionProof, + PairingTicket, PairingTranscript, PresenceProof, limits::MAX_ACCOUNT_EVENT_BYTES, +}; +use libfuzzer_sys::fuzz_target; + +type Decoder = fn(&[u8]); + +const DECODERS: [Decoder; 6] = [ + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, +]; + +fn round_trip(payload: &[u8]) { + let Ok(decoded) = T::from_canonical_bytes(payload) else { + return; + }; + assert_eq!( + decoded.to_canonical_bytes().as_deref(), + Ok(payload), + "an accepted pairing or presence object failed canonical reproduction" + ); +} + +fn canonical_payload(payload: &[u8]) -> Option> { + let Some(hexadecimal) = payload.strip_prefix(b"hex:") else { + return (payload.len() <= MAX_ACCOUNT_EVENT_BYTES).then_some(Cow::Borrowed(payload)); + }; + let hexadecimal_len = hexadecimal + .iter() + .filter(|byte| !byte.is_ascii_whitespace()) + .count(); + if !hexadecimal_len.is_multiple_of(2) || hexadecimal_len / 2 > MAX_ACCOUNT_EVENT_BYTES { + return None; + } + let mut decoded = Vec::with_capacity(hexadecimal_len / 2); + let mut high_nibble = None; + for byte in hexadecimal + .iter() + .copied() + .filter(|byte| !byte.is_ascii_whitespace()) + { + let nibble = hex_nibble(byte)?; + if let Some(high) = high_nibble.take() { + decoded.push((high << 4) | nibble); + } else { + high_nibble = Some(nibble); + } + } + Some(Cow::Owned(decoded)) +} + +const fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fuzz_target!(|input: &[u8]| { + let Some((&selector, encoded_payload)) = input.split_first() else { + return; + }; + let Some(payload) = canonical_payload(encoded_payload) else { + return; + }; + // The reviewed pairing corpus has always used ASCII `0` through `5` as its selector namespace. + // Preserve those exact values and reject unknown selectors so appending a decoder cannot + // silently remap an existing reproducer. + let Some(decoder_index) = selector.checked_sub(b'0').map(usize::from) else { + return; + }; + let Some(decoder) = DECODERS.get(decoder_index) else { + return; + }; + decoder(&payload); +}); diff --git a/fuzz/fuzz_targets/identity_provider.rs b/fuzz/fuzz_targets/identity_provider.rs new file mode 100644 index 00000000000..b2e551243ad --- /dev/null +++ b/fuzz/fuzz_targets/identity_provider.rs @@ -0,0 +1,503 @@ +#![no_main] + +use std::{ + fs::{self, OpenOptions}, + path::Path, +}; + +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, AdmissionEvidence, AlgorithmSignature, + CanonicalWire, CheckpointAuthorization, CheckpointId, ControlPolicy, ControllerApprovalBody, + ControllerApprovals, ControllerClass, ControllerDescriptor, ControllerKeyId, ControllerScope, + ControllerSelector, ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, + DelayEvidence, Digest, DurableProviderAuditor, DurationMillis, EventBody, EventPredecessors, + Extensions, FreshnessEvidence, FreshnessRequirement, HashAlgorithm, IdentityError, + KeyedSignature, MemoryProviderAuditStore, MemoryProviderStore, OpaqueProviderAnchorCommitment, + OperationKind, PolicyRule, ProtocolSignature, ProviderAdmissionControl, + ProviderAdmissionRequest, ProviderAuditExportChunk, ProviderAuditExportManifest, + ProviderCheckpointBundle, ProviderCompactionManifest, ProviderDescriptor, + ProviderExportComponent, ProviderExportComponentDescriptor, ProviderGenerationExport, + ProviderGenerationExportChunk, ProviderGenerationExportManifest, ProviderGenerationSnapshot, + ProviderHeadSigner, ProviderKeyVersion, ProviderLogId, ProviderPolicy, ProviderPolicyVersion, + ProviderRecoveryExport, ProviderRecoveryExportManifest, RecoveryAuthority, RecoveryPolicy, + RecoveryPolicyVersion, RedbProviderStore, RequiredWeight, Sequence, SignedCheckpoint, + SignedControllerApproval, SigningPublicKey, Timestamp, authorize_provider_append, + build_checkpoint_body, build_provider_checkpoint_bundle_from_genesis, + derive_provider_retention_inventory, verify_checkpoint, verify_provider_compaction, +}; +use libfuzzer_sys::fuzz_target; + +const MAX_FUZZ_INPUT_BYTES: usize = 4_096; +const MAX_HISTORY_BYTES: usize = 4 * 1_024 * 1_024; + +struct AllowAdmission; + +impl ProviderAdmissionControl for AllowAdmission { + fn check( + &self, + _admission: krikos_identity::ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +struct ProviderSigner(SecretKey); + +impl ProviderHeadSigner for ProviderSigner { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().expect("digest encodes")) + .expect("typed digest ID decodes") +} + +fn recovery_export(generation: ProviderGenerationExport) -> ProviderRecoveryExport { + let audit_store = + MemoryProviderAuditStore::new(generation.provider().clone(), generation.log_id()); + let auditor = DurableProviderAuditor::new(audit_store.clone()); + if let Some(head) = generation.latest_head() { + auditor + .observe(head.clone(), None) + .expect("provider head audits"); + } + ProviderRecoveryExport::new( + generation, + audit_store.snapshot().expect("provider audit snapshot"), + ) + .expect("provider recovery export") +} + +fn bounded_fault_offset(input: &[u8], start: usize, upper_bound: u64) -> u64 { + if upper_bound == 0 { + return 0; + } + let mut bytes = [0_u8; 8]; + for (output, value) in bytes.iter_mut().zip(input.iter().skip(start)) { + *output = *value; + } + u64::from_le_bytes(bytes) % upper_bound +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).expect("valid signing key"), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).expect("nonzero controller weight"), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .expect("valid controller") +} + +fn rule(operation: OperationKind) -> PolicyRule { + PolicyRule::new( + operation, + RequiredWeight::new(1).expect("nonzero policy weight"), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .expect("valid policy rule") +} + +fn checkpoint_bundle(seed: u8) -> ProviderCheckpointBundle { + let signer = SecretKey::from_bytes(&[seed; 32]); + let added = SecretKey::from_bytes(&[seed.wrapping_add(1); 32]); + let control_policy = ControlPolicy::new( + vec![ + rule(OperationKind::AddController), + rule(OperationKind::ChangeProviderPolicy), + ], + Extensions::default(), + ) + .expect("valid control policy"); + let recovery_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).expect("nonzero recovery weight"), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .expect("valid recovery policy"); + let genesis = AccountGenesis::new( + [seed; 32], + Timestamp::from_unix_millis(1), + control_policy, + vec![controller(&signer)], + recovery_policy, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()) + .expect("valid provider policy"), + Extensions::default(), + ) + .expect("valid genesis"); + let mut state = AccountState::from_genesis(&genesis).expect("genesis projects"); + let operation = AccountOperation::AddController(controller(&added)); + let body = EventBody::new( + state.account_id(), + Sequence::new(1), + state + .expected_epoch_for(&operation) + .expect("operation epoch"), + EventPredecessors::genesis(state.genesis_anchor()), + operation, + Timestamp::from_unix_millis(2), + [seed.max(1); 16], + Extensions::default(), + ) + .expect("valid event body"); + let preceding_checkpoint = typed_id::(seed.wrapping_add(2)); + let evidence = AdmissionEvidence::new( + body.proposal_id().expect("proposal ID"), + preceding_checkpoint, + state.provider_policy_id(), + FreshnessEvidence::local_known(preceding_checkpoint), + DelayEvidence::none(), + Extensions::default(), + ) + .expect("valid admission evidence"); + let event_id = body + .admitted_event_id( + evidence + .admission_evidence_id() + .expect("admission evidence ID"), + ) + .expect("event ID"); + let signing_key = + SigningPublicKey::ed25519(*signer.public().as_bytes()).expect("valid signing key"); + let controller_id = state.active_controllers()[0].id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + event_id, + evidence + .admission_evidence_id() + .expect("admission evidence ID"), + Extensions::default(), + ) + .expect("valid event approval body"); + let event_signature = signer.sign( + &approval_body + .to_canonical_bytes() + .expect("event approval encodes"), + ); + let event_approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .expect("v1 suite") + .crypto_suite_id() + .expect("suite ID"), + ControllerKeyId::for_signing_key(&signing_key).expect("controller key ID"), + AlgorithmSignature::new(1, event_signature.to_bytes().to_vec()) + .expect("valid event signature"), + )], + ) + .expect("valid event approval"); + let event = krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![event_approval]).expect("event approval set"), + ) + .expect("authorized event"); + state + .validate_and_apply(&event) + .expect("event projects into state"); + + let checkpoint_body = + build_checkpoint_body(&state, Timestamp::from_unix_millis(3)).expect("checkpoint body"); + let checkpoint_id = checkpoint_body.checkpoint_id().expect("checkpoint ID"); + let checkpoint_approval_body = + ControllerApprovalBody::checkpoint(controller_id, checkpoint_id, Extensions::default()) + .expect("valid checkpoint approval body"); + let checkpoint_signature = signer.sign( + &checkpoint_approval_body + .to_canonical_bytes() + .expect("checkpoint approval encodes"), + ); + let checkpoint_approval = SignedControllerApproval::new( + checkpoint_approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .expect("v1 suite") + .crypto_suite_id() + .expect("suite ID"), + ControllerKeyId::for_signing_key(&signing_key).expect("controller key ID"), + AlgorithmSignature::new(1, checkpoint_signature.to_bytes().to_vec()) + .expect("valid checkpoint signature"), + )], + ) + .expect("valid checkpoint approval"); + let checkpoint = SignedCheckpoint::new( + checkpoint_body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![checkpoint_approval]).expect("checkpoint approval set"), + ) + .expect("checkpoint authorization"), + ) + .expect("signed checkpoint"); + verify_checkpoint(&state, &checkpoint, None).expect("checkpoint verifies"); + build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&event), + &checkpoint, + None, + ) + .expect("provider checkpoint bundle") +} + +fn append_bundle( + store: &RedbProviderStore, + bundle: &ProviderCheckpointBundle, + observed_at: u64, + signer: &ProviderSigner, +) -> krikos_identity::InclusionReceipt { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).expect("bounded admission"); + let permit = authorize_provider_append(admission, request, &AllowAdmission) + .expect("verified append permit"); + store + .append(permit, Timestamp::from_unix_millis(observed_at), signer) + .expect("provider append") +} + +fn assert_corrupt_reopen_fails_closed( + path: &Path, + provider: &ProviderDescriptor, + log_id: ProviderLogId, + committed_snapshot: &ProviderGenerationSnapshot, + committed_export: &ProviderGenerationExport, +) { + match RedbProviderStore::open(path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) { + Ok(reopened) => { + let snapshot = reopened + .snapshot() + .expect("a successfully reopened generation authenticates"); + assert_eq!( + snapshot, *committed_snapshot, + "corruption normalized provider state" + ); + assert_eq!( + reopened + .export_generation() + .expect("reopened provider export"), + *committed_export, + "corruption changed authenticated provider export" + ); + } + Err(_typed_error) => {} + } +} + +fuzz_target!(|input: &[u8]| { + if input.is_empty() || input.len() > MAX_FUZZ_INPUT_BYTES { + return; + } + match input[0] { + b'7' => { + let _ = ProviderExportComponent::from_canonical_bytes(&input[1..]); + return; + } + b'8' => { + let _ = ProviderExportComponentDescriptor::from_canonical_bytes(&input[1..]); + return; + } + b'9' => { + let _ = ProviderGenerationExportChunk::from_canonical_bytes(&input[1..]); + return; + } + b'a' => { + let _ = ProviderAuditExportChunk::from_canonical_bytes(&input[1..]); + return; + } + b'b' => { + let _ = ProviderGenerationExportManifest::from_canonical_bytes(&input[1..]); + return; + } + b'c' => { + let _ = ProviderAuditExportManifest::from_canonical_bytes(&input[1..]); + return; + } + b'd' => { + let _ = ProviderRecoveryExportManifest::from_canonical_bytes(&input[1..]); + return; + } + b'e' => { + let _ = ProviderCompactionManifest::from_canonical_bytes(&input[1..]); + return; + } + b'f' => { + let _ = OpaqueProviderAnchorCommitment::from_canonical_bytes(&input[1..]); + return; + } + _ => {} + } + // Selectors `0` through `6` are the original persistent-store scenarios. Interchange + // decoders extend that ASCII namespace append-only at `7` through `f` above. + // Reject every other value rather than wrapping or reducing it modulo the scenario count. + let Some(selector) = input[0].checked_sub(b'0').filter(|selector| *selector < 7) else { + return; + }; + let first_seed = input.get(1).copied().unwrap_or(0x31).max(1); + let second_seed = first_seed.wrapping_add(1).max(1); + let provider_signer = ProviderSigner(SecretKey::from_bytes(&[0x71; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_signer.0.public().as_bytes()) + .expect("valid provider key"), + Extensions::default(), + ) + .expect("valid provider"); + let log_id = typed_id::(0x72); + let directory = tempfile::tempdir().expect("temporary provider directory"); + let path = directory.path().join("provider.redb"); + let store = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .expect("provider store opens"); + let first_bundle = checkpoint_bundle(first_seed); + let second_bundle = checkpoint_bundle(second_seed); + let first_receipt = append_bundle(&store, &first_bundle, 10, &provider_signer); + let second_receipt = append_bundle(&store, &second_bundle, 11, &provider_signer); + assert_eq!(first_receipt.leaf_index(), 0); + assert_eq!(second_receipt.leaf_index(), 1); + let committed = store.snapshot().expect("provider snapshot"); + let committed_export = store.export_generation().expect("provider export"); + assert_eq!(committed.tree_size(), 2); + + match selector { + 0 => { + drop(store); + let reopened = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .expect("provider reopens"); + assert_eq!(reopened.snapshot().expect("reopened snapshot"), committed); + let account_id = first_bundle.provider_log_admission().account_id(); + let page = reopened + .account_history(account_id, None, 1, MAX_HISTORY_BYTES) + .expect("bounded account history"); + assert_eq!(page.records().len(), 1); + } + 1 => { + let replay = append_bundle(&store, &first_bundle, 12, &provider_signer); + assert_eq!(replay.leaf_index(), first_receipt.leaf_index()); + assert_eq!(store.snapshot().expect("replay snapshot").tree_size(), 2); + store.consistency_proof(0, 2).expect("empty prefix proof"); + store.consistency_proof(1, 2).expect("proper prefix proof"); + store.consistency_proof(2, 2).expect("equal prefix proof"); + } + 2 => { + let source = store.export_generation().expect("provider export"); + let mirror = MemoryProviderStore::restore_generation(source.clone()) + .expect("provider mirror restores"); + assert_eq!(mirror.snapshot().expect("mirror snapshot"), committed); + let mirror_export = mirror.export_generation().expect("mirror export"); + let source_recovery = recovery_export(source); + let mirror_recovery = recovery_export(mirror_export); + let inventory = + derive_provider_retention_inventory(&source_recovery).expect("retention inventory"); + let authorization = + verify_provider_compaction(&source_recovery, &mirror_recovery, &inventory) + .expect("compaction authorization"); + store + .record_compaction_manifest(&authorization, &mirror_recovery, &inventory) + .expect("durable compaction manifest"); + drop(store); + let reopened = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .expect("compacted provider reopens"); + assert_eq!( + reopened + .compaction_manifests() + .expect("recorded manifests") + .len(), + 1 + ); + reopened + .consistency_proof(1, 2) + .expect("proof survives compaction authorization"); + } + 3 => { + drop(store); + let mut bytes = fs::read(&path).expect("provider bytes"); + if !bytes.is_empty() { + let length = u64::try_from(bytes.len()).expect("provider file length fits u64"); + let offset = usize::try_from(bounded_fault_offset(input, 2, length)) + .expect("bounded provider byte offset fits usize"); + bytes[offset] ^= input.get(3).copied().unwrap_or(1).max(1); + fs::write(&path, bytes).expect("fault injection write"); + } + assert_corrupt_reopen_fails_closed( + &path, + &provider, + log_id, + &committed, + &committed_export, + ); + } + 4 => { + drop(store); + let length = fs::metadata(&path).expect("provider metadata").len(); + let retained = bounded_fault_offset(input, 2, length); + OpenOptions::new() + .write(true) + .open(&path) + .expect("provider file opens for fault injection") + .set_len(retained) + .expect("provider truncation"); + assert!( + RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .is_err(), + "truncated provider generation normalized into a valid store" + ); + } + 5 => { + drop(store); + assert!(matches!( + RedbProviderStore::open( + &path, + provider.clone(), + typed_id::(0x73), + ProviderKeyVersion::GENESIS, + ), + Err(IdentityError::InvalidRelationship { + resource: "provider store generation" + }) + )); + } + _ => { + let admission = first_bundle.provider_log_admission(); + let exact = + ProviderAdmissionRequest::for_admission(&admission).expect("exact admission size"); + let undercharged = ProviderAdmissionRequest::new(exact.encoded_bytes() - 1) + .expect("positive undercharge"); + assert!(matches!( + authorize_provider_append(admission, undercharged, &AllowAdmission), + Err(IdentityError::InvalidRelationship { + resource: "provider append request byte undercharge" + }) + )); + assert_eq!(store.snapshot().expect("unchanged snapshot"), committed); + } + } +}); diff --git a/fuzz/fuzz_targets/identity_schema.rs b/fuzz/fuzz_targets/identity_schema.rs new file mode 100644 index 00000000000..fd5fe7ab31a --- /dev/null +++ b/fuzz/fuzz_targets/identity_schema.rs @@ -0,0 +1,187 @@ +#![no_main] + +use krikos_identity::{ + AccountGenesis, AccountOperation, ActivateCryptoMigration, AdmissionEvidence, AgreementKeyId, + ApplicationEventBody, ApplicationEventCounter, AuthorizationContext, AuthorizedEvent, + BackupAuthorityBundle, BackupEnvelope, BeginCryptoMigration, BeginRecovery, BlindedCommitment, + BlindedMetadataCommitment, CancelRecovery, CanonicalWire, CapabilityAction, + CapabilityConstraint, CapabilityGrant, CapabilityNamespace, CapabilityRoot, + CheckpointAuthorization, CheckpointBody, CheckpointTransitionKind, ControlPolicy, + ControllerApprovalBody, ControllerApprovals, ControllerClass, ControllerClassSet, + ControllerDescriptor, ControllerIdSet, ControllerKeyBinding, ControllerKeyBindingProof, + ControllerKeyBindingProofSet, ControllerScope, ControllerSelector, CredentialClaim, + CryptoMigrationBody, CryptoSuiteDescriptor, DelayEvidence, DelegationBody, DelegationChain, + DelegationPermission, DeviceAuthorization, DeviceAuthorizationUpdate, DeviceClass, + DeviceDescriptor, DeviceMetadataUpdate, DeviceUpdate, EndpointPublicKey, EventBody, + EventIntentApprovalBody, EventIntentApprovals, EventPredecessors, FinalizeRecovery, + ForkDescriptor, FreshnessEvidence, GroupKeyWrapHeader, GuardianApprovalBody, + GuardianApprovalDecision, GuardianApprovalSet, GuardianSetRoot, InclusionReceipt, KeyWrapNonce, + KeyedSignature, NameClaimBody, NormalizedName, PairwiseIdentifier, PortableCredentialBody, + PrivateArtifactContext, PrivateCheckpointLookupHandle, PrivateMetadataEnvelope, + ProtocolUpgrade, ProviderDescriptor, ProviderHeadBody, ProviderLogEntryBody, ProviderPolicy, + ProviderReceipts, RecipientKeyWraps, RecoveryAuthorityPlan, RecoveryDelayAnchor, + RecoveryPolicy, RecoveryPolicyVersion, RecoveryProposal, RecoveryThresholdEvidence, + ReinstateDevice, RelyingPartyContext, ResolveFork, ResourcePath, ResourceSegment, + ResourceSelector, RetireAccount, RetireCryptoSuite, RetireCryptoSuiteMode, RevokeDevice, + RotateDeviceKeys, SignedApplicationEvent, SignedCheckpoint, SignedControllerApproval, + SignedDelegation, SignedEventIntentApproval, SignedGuardianApproval, SignedNameClaim, + SignedPortableCredential, SignedProviderHead, SignedSocialAttestation, SocialAttestationBody, + SuspendDevice, TransitionCheckpointWitness, UpgradeCompatibility, VetoRecovery, + WrappedGroupKey, limits::MAX_ENCODED_OBJECT_BYTES, +}; +use libfuzzer_sys::fuzz_target; + +type SchemaDecoder = fn(&[u8]); + +// Keep this registry explicit: adding a public canonical schema should be a reviewed fuzz-coverage +// decision. ResourceSegment deliberately remains at index 37 because the reviewed text corpus +// starts with ASCII `%` (37) followed by a canonical 33-byte segment. +const SCHEMA_DECODERS: [SchemaDecoder; 112] = [ + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + // AdmissionEvidence remains at index 43 for the reviewed v1 corpus seed. + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + // Raw grants/openings have no public canonical decoder. SignedGuardianApproval at index 84 + // retains fuzz coverage for their private nested decoder and validation boundary. + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + // Task 8 social records: indices 96..98. + round_trip::, + round_trip::, + // Task 8 names: indices 98..101. + round_trip::, + round_trip::, + round_trip::, + // Task 8 private/public envelopes and privacy-preserving identifiers: indices 101..112. + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, +]; + +fn round_trip(payload: &[u8]) { + let Ok(decoded) = T::from_canonical_bytes(payload) else { + return; + }; + let encoded = decoded.to_canonical_bytes(); + assert_eq!( + encoded.as_deref(), + Ok(payload), + "an accepted identity schema failed to reproduce its canonical bytes" + ); +} + +fuzz_target!(|input: &[u8]| { + let Some((&selector, payload)) = input.split_first() else { + return; + }; + if payload.len() > MAX_ENCODED_OBJECT_BYTES { + return; + } + + // Selector values are append-only. Never reduce them modulo the table length: doing so would + // silently retarget saved corpus inputs whenever a decoder is appended. + let decoder_index = usize::from(selector); + let Some(decoder) = SCHEMA_DECODERS.get(decoder_index) else { + return; + }; + decoder(payload); +}); diff --git a/fuzz/fuzz_targets/identity_semantics.rs b/fuzz/fuzz_targets/identity_semantics.rs new file mode 100644 index 00000000000..12163797f8c --- /dev/null +++ b/fuzz/fuzz_targets/identity_semantics.rs @@ -0,0 +1,1076 @@ +#![no_main] + +use std::convert::Infallible; + +use futures_lite::future::block_on; +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountId, AccountStore, AdmissionEvidenceId, AgreementPublicKey, + AgreementSecretKey, AlgorithmPublicKey, AlgorithmSignature, ApplicationAuthorizationView, + ApplicationDeviceStatus, ApplicationEventBody, ApplicationEventCounter, ApplicationEventId, + ApplicationId, AuthorizationContext, AuthorizedEvent, CanonicalWire, CapabilityGrantId, + CheckpointId, ClaimEffects, ControlPolicyId, ControllerApprovalId, ControllerId, + ControllerKeyId, ControllerWeight, CredentialClaim, CredentialVerificationContext, + CryptoMigrationId, CryptoStateId, CryptoSuiteId, DelegationDepth, DelegationId, + DeviceAuthorization, DeviceAuthorizationProposalId, DeviceClass, DeviceDescriptor, DeviceId, + DevicePresenceChallenge, Digest, DurableProviderAuditor, DurationMillis, EndpointPublicKey, + Epoch, EventAuthorizationId, EventId, EventIntentApprovalId, Extensions, ForkCommonAncestor, + ForkId, FreshnessEvidence, FreshnessRequirement, GenesisAnchor, GroupId, GroupKeyEpoch, + GroupKeyWrapId, GuardianApprovalSet, GuardianAuthorityContext, GuardianGrantId, + GuardianThreshold, HashAlgorithm, IdentityError, LeaseId, MemoryAccountStore, + MemoryOperationalEffectStore, MemoryProviderAuditStore, MemoryProviderStore, + NameAuthorityContext, NameCandidateSet, NameClaimBody, NormalizedName, + OperationalEffectJournal, OperationalEffectPhase, PairingChallenge, PairingConfirmationContext, + PairingNonce, PairingProofId, PairingSessionId, PairingTicketId, PairingTranscriptId, + PortableCredentialBody, PresenceProof, PresenceProofId, PresenceSessionId, + PresenceVerifierChallenge, PrivateArtifactContext, PrivateMetadata, PrivateMetadataEnvelope, + PrivateMetadataKey, ProjectionEffect, ProposalId, ProtocolMajor, ProtocolSignature, + ProviderAdmissionControl, ProviderAdmissionRequest, ProviderCheckpointBundle, + ProviderDescriptor, ProviderGenerationExport, ProviderGenerationRegistry, ProviderHeadSigner, + ProviderId, ProviderKeyVersion, ProviderLogAdmission, ProviderLogId, ProviderPolicy, + ProviderPolicyId, ProviderPolicyVersion, ProviderQuorum, ProviderRecoveryExport, + RecoveryAuthority, RecoveryId, RecoveryPolicy, RecoveryPolicyId, RecoveryPolicyVersion, + RequiredWeight, RevocationReasonCode, ShortAuthString, SignedApplicationEvent, + SignedCheckpoint, SignedNameClaim, SignedPortableCredential, SignedSocialAttestation, + SigningPublicKey, SocialAttestationBody, SocialAttestationVerificationContext, + SocialTransitivityPolicy, Timestamp, TofuDecision, authorize_provider_append, + build_provider_checkpoint_bundle_from_genesis, derive_provider_retention_inventory, + evaluate_freshness, evaluate_name_tofu, evaluate_social_trust, + limits::MAX_ALGORITHM_SIGNATURE_BYTES, verify_application_event, verify_guardian_authority, + verify_name_candidates, verify_name_claim, verify_portable_credential, verify_presence_proof, + verify_provider_compaction, verify_social_attestation, +}; +use libfuzzer_sys::fuzz_target; +use rand_core::{TryCryptoRng, TryRng}; + +type IdentitySemanticsDecoder = fn(&[u8]); + +/// One selector byte plus the largest bounded leaf payload in this registry. +const MAX_SEMANTICS_PAYLOAD_BYTES: usize = MAX_ALGORITHM_SIGNATURE_BYTES.saturating_add(16); + +// This named inventory is intentionally kept beside the decoder table. Tooling can compare these +// exact public type names with the crate's sealed CanonicalWire implementations without inferring +// names from generic function pointers. +const IDENTITY_SEMANTICS_TYPE_NAMES: [&str; 53] = [ + "AccountId", + "AdmissionEvidenceId", + "AlgorithmPublicKey", + "AlgorithmSignature", + "ApplicationEventId", + "ApplicationId", + "CapabilityGrantId", + "CheckpointId", + "ControlPolicyId", + "ControllerApprovalId", + "ControllerId", + "ControllerKeyId", + "ControllerWeight", + "CryptoMigrationId", + "CryptoStateId", + "CryptoSuiteId", + "DelegationDepth", + "DelegationId", + "DeviceAuthorizationProposalId", + "DeviceId", + "EventAuthorizationId", + "EventId", + "EventIntentApprovalId", + "ForkCommonAncestor", + "ForkId", + "GenesisAnchor", + "GroupId", + "GroupKeyEpoch", + "GroupKeyWrapId", + "GuardianGrantId", + "PairingChallenge", + "PairingConfirmationContext", + "PairingNonce", + "PairingProofId", + "PairingSessionId", + "PairingTicketId", + "PairingTranscriptId", + "PresenceProofId", + "PresenceSessionId", + "PresenceVerifierChallenge", + "ProposalId", + "ProtocolMajor", + "ProviderId", + "ProviderKeyVersion", + "ProviderLogId", + "ProviderPolicyId", + "ProviderPolicyVersion", + "ProviderQuorum", + "RecoveryId", + "RecoveryPolicyId", + "RequiredWeight", + "RevocationReasonCode", + "ShortAuthString", +]; + +const IDENTITY_SEMANTICS_DECODERS: [IdentitySemanticsDecoder; 53] = [ + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, + round_trip::, +]; + +fn round_trip(payload: &[u8]) { + let Ok(decoded) = T::from_canonical_bytes(payload) else { + return; + }; + assert_eq!( + decoded.to_canonical_bytes().as_deref(), + Ok(payload), + "an accepted identity semantics leaf failed canonical reproduction" + ); +} + +const REJECT_RELATIONSHIP_CONTROL: u8 = b'!'; + +fn reject_relationship(payload: &[u8]) -> bool { + payload.first() == Some(&REJECT_RELATIONSHIP_CONTROL) +} + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + let encoded = digest + .to_canonical_bytes() + .expect("a fixed BLAKE3 digest must have a canonical encoding"); + T::from_canonical_bytes(&encoded) + .expect("a typed digest identifier must accept a canonical Digest encoding") +} + +fn guardian_authority_semantics(reject: bool) { + let approvals = GuardianApprovalSet::from_canonical_bytes(include_bytes!( + "../../protocols/krikos-identity/tests/vectors/guardian-approval-set.bin" + )) + .expect("the reviewed guardian approval fixture must remain canonical"); + let policy_version = RecoveryPolicyVersion::new(7); + let required_weight = + RequiredWeight::new(2).expect("the fixed guardian threshold weight is nonzero"); + let threshold = GuardianThreshold::new(approvals.guardian_set_root(), 3, 3, required_weight) + .expect("the fixed guardian threshold is internally satisfiable"); + let policy = RecoveryPolicy::new( + policy_version, + RecoveryAuthority::guardian_threshold(threshold), + DurationMillis::new(1_000), + DurationMillis::new(30_000), + Extensions::default(), + ) + .expect("the reviewed guardian policy must remain valid"); + let first = approvals + .as_slice() + .first() + .expect("the canonical guardian approval set is nonempty"); + let body = first.body(); + let valid_context = GuardianAuthorityContext::try_new( + body.protected_account_id(), + body.recovery_id(), + policy + .id() + .expect("the fixed recovery policy must derive an identifier"), + policy_version, + body.account_epoch(), + body.decision(), + Timestamp::from_unix_millis(50_100), + ) + .expect("the fixed guardian authority context must be valid"); + let verified = verify_guardian_authority(&policy, &approvals, &valid_context) + .expect("the reviewed guardian approvals must satisfy their exact policy"); + assert_eq!( + verified.approval_count(), + 2, + "the reviewed guardian fixture must retain two distinct approvals" + ); + + if reject { + let substituted = GuardianAuthorityContext::try_new( + body.protected_account_id(), + typed_id::(0x99), + valid_context.recovery_policy_id(), + policy_version, + body.account_epoch(), + body.decision(), + Timestamp::from_unix_millis(50_100), + ) + .expect("the substituted guardian context is structurally valid"); + assert!( + verify_guardian_authority(&policy, &approvals, &substituted).is_err(), + "guardian authority must reject a substituted recovery identifier" + ); + } +} + +fn social_semantics(reject: bool) { + let issuer = SecretKey::from_bytes(&[0x11; 32]); + let subject = SecretKey::from_bytes(&[0x12; 32]); + let issuer_key = SigningPublicKey::ed25519(*issuer.public().as_bytes()) + .expect("the fixed issuer key is valid Ed25519 material"); + let subject_key = SigningPublicKey::ed25519(*subject.public().as_bytes()) + .expect("the fixed subject key is valid Ed25519 material"); + let claim_digest = Digest::new(HashAlgorithm::Blake3_256, [0x17; 32]); + let body = SocialAttestationBody::try_new( + typed_id::(0x13), + typed_id::(0x14), + issuer_key, + typed_id::(0x15), + typed_id::(0x16), + subject_key, + claim_digest, + Timestamp::from_unix_millis(10), + Some(Timestamp::from_unix_millis(20)), + Extensions::default(), + ) + .expect("the fixed social attestation body must be valid"); + let signature = AlgorithmSignature::new( + 1, + issuer + .sign( + &body + .signing_bytes() + .expect("the social body must produce signing bytes"), + ) + .to_bytes() + .to_vec(), + ) + .expect("the fixed social signature must use the v1 algorithm shape"); + let attestation = SignedSocialAttestation::try_new(body.clone(), signature) + .expect("the fixed social attestation signature must verify"); + let context = SocialAttestationVerificationContext::try_new( + body.issuer_account_id(), + body.issuer_checkpoint_id(), + body.issuer_signing_key(), + body.subject_account_id(), + body.subject_checkpoint_id(), + body.subject_signing_key(), + body.claim_digest(), + Timestamp::from_unix_millis(19), + ) + .expect("the fixed social verification context must be valid"); + let verified = verify_social_attestation(&attestation, &context) + .expect("the fixed social attestation must verify at its authority time"); + let hint = evaluate_social_trust( + &[verified], + SocialTransitivityPolicy::default(), + Timestamp::from_unix_millis(19), + ) + .expect("one verified social edge is valid with transitivity disabled"); + assert_eq!( + hint.depth(), + 1, + "one verified social edge must have depth one" + ); + + if reject { + let expired = SocialAttestationVerificationContext::try_new( + body.issuer_account_id(), + body.issuer_checkpoint_id(), + body.issuer_signing_key(), + body.subject_account_id(), + body.subject_checkpoint_id(), + body.subject_signing_key(), + body.claim_digest(), + Timestamp::from_unix_millis(20), + ) + .expect("the expired social context is structurally valid"); + assert!( + verify_social_attestation(&attestation, &expired).is_err(), + "social verification must reject its exclusive expiry boundary" + ); + } +} + +fn name_semantics(reject: bool) { + let secret = SecretKey::from_bytes(&[0x41; 32]); + let signing_key = SigningPublicKey::ed25519(*secret.public().as_bytes()) + .expect("the fixed name key is valid Ed25519 material"); + let body = NameClaimBody::try_new( + NormalizedName::try_new("alice.example") + .expect("the fixed lowercase DNS-style name is normalized"), + typed_id::(0x42), + typed_id::(0x43), + signing_key, + Timestamp::from_unix_millis(10), + Some(Timestamp::from_unix_millis(20)), + Extensions::default(), + ) + .expect("the fixed name claim body must be valid"); + let signature = AlgorithmSignature::new( + 1, + secret + .sign( + &body + .signing_bytes() + .expect("the name claim must produce signing bytes"), + ) + .to_bytes() + .to_vec(), + ) + .expect("the fixed name signature must use the v1 algorithm shape"); + let claim = SignedNameClaim::try_new(body.clone(), signature) + .expect("the fixed self-signed name claim must verify"); + let context = NameAuthorityContext::try_new( + body.name().clone(), + body.subject_account_id(), + body.subject_checkpoint_id(), + body.subject_signing_key(), + Timestamp::from_unix_millis(19), + ) + .expect("the fixed name authority context must be valid"); + let verified = verify_name_claim(&claim, &context) + .expect("the fixed name claim must verify against its exact authority"); + assert!( + matches!( + evaluate_name_tofu(None, &verified) + .expect("a verified name claim must produce a TOFU decision"), + TofuDecision::FirstUse { .. } + ), + "a name without prior observation must remain first-use" + ); + let candidates = NameCandidateSet::try_new(vec![claim.clone()]) + .expect("one fixed name candidate is bounded"); + assert_eq!( + verify_name_candidates(&candidates, std::slice::from_ref(&context)) + .expect("the fixed candidate set must be evaluable") + .as_slice() + .len(), + 1, + "the exact name authority must retain its one matching candidate" + ); + + if reject { + let substituted = NameAuthorityContext::try_new( + body.name().clone(), + body.subject_account_id(), + typed_id::(0x44), + body.subject_signing_key(), + Timestamp::from_unix_millis(19), + ) + .expect("the substituted name context is structurally valid"); + assert!( + verify_name_claim(&claim, &substituted).is_err(), + "name verification must reject a substituted checkpoint" + ); + } +} + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +fn private_metadata_semantics(reject: bool) { + let context = PrivateArtifactContext::try_new( + typed_id::(1), + typed_id::(2), + Epoch::new(3), + Some(typed_id::(4)), + 5, + Extensions::default(), + ) + .expect("the fixed private-artifact context must be valid"); + let key = + PrivateMetadataKey::try_new([0x31; 32]).expect("the fixed private-metadata key is nonzero"); + let plaintext = PrivateMetadata::try_new(b"private profile: alpine orchid".to_vec()) + .expect("the fixed private metadata is nonempty and bounded"); + let envelope = + PrivateMetadataEnvelope::seal_with_rng(context, &key, &plaintext, &mut RepeatingRng(0x41)) + .expect("fixed entropy must seal private metadata"); + assert_eq!( + envelope + .open(&key) + .expect("the exact private metadata key must authenticate") + .as_bytes(), + plaintext.as_bytes(), + "authenticated private metadata must reproduce its exact plaintext" + ); + + if reject { + let wrong_key = PrivateMetadataKey::try_new([0x32; 32]) + .expect("the substituted private-metadata key is nonzero"); + assert_eq!( + envelope.open(&wrong_key), + Err(IdentityError::PrivateArtifactAuthenticationFailed), + "private metadata must reject a substituted key" + ); + } +} + +fn portable_credential_semantics(reject: bool) { + let issuer = SecretKey::from_bytes(&[0x41; 32]); + let subject = SecretKey::from_bytes(&[0x42; 32]); + let issuer_key = SigningPublicKey::ed25519(*issuer.public().as_bytes()) + .expect("the fixed credential issuer key is valid Ed25519 material"); + let subject_key = SigningPublicKey::ed25519(*subject.public().as_bytes()) + .expect("the fixed credential subject key is valid Ed25519 material"); + let account_id = typed_id::(0x43); + let checkpoint_id = typed_id::(0x44); + let body = PortableCredentialBody::try_new( + account_id, + checkpoint_id, + Epoch::GENESIS, + vec![subject_key], + account_id, + issuer_key, + Timestamp::from_unix_millis(10), + Timestamp::from_unix_millis(20), + vec![ + CredentialClaim::try_new("display-name", b"Ada".to_vec()) + .expect("the fixed credential claim is bounded"), + ], + Extensions::default(), + ) + .expect("the fixed portable credential body must be valid"); + let signature = AlgorithmSignature::new( + 1, + issuer + .sign( + &body + .signing_bytes() + .expect("the credential body must produce signing bytes"), + ) + .to_bytes() + .to_vec(), + ) + .expect("the fixed credential signature must use the v1 algorithm shape"); + let credential = SignedPortableCredential::try_new(body.clone(), signature) + .expect("the fixed portable credential signature must verify"); + let context = CredentialVerificationContext::try_new( + body.account_id(), + body.checkpoint_id(), + body.account_epoch(), + body.issuer_account_id(), + body.issuer_signing_key(), + Timestamp::from_unix_millis(19), + ) + .expect("the fixed credential verification context must be valid"); + let verified = verify_portable_credential(&credential, &context) + .expect("the fixed portable credential must verify"); + assert_eq!( + verified.claims().len(), + 1, + "the fixed portable credential must reveal one selected claim" + ); + + if reject { + let substituted = CredentialVerificationContext::try_new( + body.account_id(), + typed_id::(0x45), + body.account_epoch(), + body.issuer_account_id(), + body.issuer_signing_key(), + Timestamp::from_unix_millis(19), + ) + .expect("the substituted credential context is structurally valid"); + assert!( + verify_portable_credential(&credential, &substituted).is_err(), + "portable credential verification must reject a substituted checkpoint" + ); + } +} + +struct FixedAuthorizationView { + context: AuthorizationContext, + status: ApplicationDeviceStatus, + authorization: DeviceAuthorization, +} + +impl ApplicationAuthorizationView for FixedAuthorizationView { + fn authorization_context(&self) -> AuthorizationContext { + self.context + } + + fn device_status(&self, device_id: DeviceId) -> ApplicationDeviceStatus { + if device_id == self.authorization.device_id() { + self.status + } else { + ApplicationDeviceStatus::Unknown + } + } + + fn device_authorization(&self, device_id: DeviceId) -> Option<&DeviceAuthorization> { + (device_id == self.authorization.device_id()).then_some(&self.authorization) + } +} + +fn application_authorization(secret: &SecretKey) -> DeviceAuthorization { + let endpoint_secret = SecretKey::from_bytes(&[0x32; 32]); + let descriptor = DeviceDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()) + .expect("the fixed application key is valid Ed25519 material"), + AgreementPublicKey::x25519([ + 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]) + .expect("the fixed X25519 public key is contributory"), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*endpoint_secret.public().as_bytes()) + .expect("the fixed endpoint key is valid Ed25519 material"), + ), + Extensions::default(), + ) + .expect("the fixed application device descriptor must keep key roles distinct"); + DeviceAuthorization::new( + descriptor + .id() + .expect("the fixed application device must derive an identifier"), + descriptor, + DeviceClass::ApplicationOnly, + None, + Vec::new(), + Epoch::new(3), + Extensions::default(), + ) + .expect("the fixed application device authorization must be valid") +} + +fn application_event_semantics(reject: bool) { + let secret = SecretKey::from_bytes(&[0x31; 32]); + let authorization = application_authorization(&secret); + let context = AuthorizationContext::new( + typed_id::(0x41), + Epoch::new(7), + typed_id::(0x42), + ); + let body = ApplicationEventBody::new( + context.account_id(), + ApplicationId::new(Digest::new(HashAlgorithm::Blake3_256, [0x43; 32])), + authorization.device_id(), + context.epoch(), + context.checkpoint_id(), + ApplicationEventCounter::new(11), + b"payload".to_vec(), + Extensions::default(), + ) + .expect("the fixed application event body must be valid"); + let signature = secret.sign( + &body + .signing_bytes() + .expect("the application event must produce signing bytes"), + ); + let event = SignedApplicationEvent::new(body, ProtocolSignature::ed25519(signature.to_bytes())) + .expect("the fixed signed application event must be bounded"); + let view = FixedAuthorizationView { + context, + status: ApplicationDeviceStatus::Active, + authorization, + }; + assert_eq!( + verify_application_event(&event, &view).expect("the fixed application event must verify"), + event + .application_event_id() + .expect("the fixed application event must derive an identifier"), + "application verification must return the complete envelope identifier" + ); + + if reject { + let rejected_view = FixedAuthorizationView { + context, + status: ApplicationDeviceStatus::Revoked, + authorization: view.authorization.clone(), + }; + assert_eq!( + verify_application_event(&event, &rejected_view), + Err(IdentityError::DeviceRevoked), + "application verification must reject a revoked device" + ); + } +} + +fn presence_semantics(reject: bool) { + let application_secret = SecretKey::from_bytes(&[10; 32]); + let agreement_secret = AgreementSecretKey::from_bytes([11; 32]); + let endpoint_secret = SecretKey::from_bytes(&[12; 32]); + let descriptor = DeviceDescriptor::new( + SigningPublicKey::ed25519(*application_secret.public().as_bytes()) + .expect("the fixed presence application key is valid Ed25519 material"), + agreement_secret + .public_key() + .expect("the fixed presence agreement secret must derive a public key"), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*endpoint_secret.public().as_bytes()) + .expect("the fixed presence endpoint key is valid Ed25519 material"), + ), + Extensions::default(), + ) + .expect("the fixed presence device descriptor must keep key roles distinct"); + let device_id = descriptor + .id() + .expect("the fixed presence device must derive an identifier"); + let account_id = typed_id::(1); + let checkpoint_id = typed_id::(2); + let authorization = DeviceAuthorization::new( + device_id, + descriptor.clone(), + DeviceClass::GeneralPurpose, + None, + Vec::new(), + Epoch::new(7), + Extensions::default(), + ) + .expect("the fixed presence authorization must be valid"); + let view = FixedAuthorizationView { + context: AuthorizationContext::new(account_id, Epoch::new(7), checkpoint_id), + status: ApplicationDeviceStatus::Active, + authorization, + }; + let challenge = DevicePresenceChallenge::new( + account_id, + device_id, + PresenceVerifierChallenge::new([0x31; 32]) + .expect("the fixed presence verifier challenge is nonzero"), + PresenceSessionId::new([0x41; 32]) + .expect("the fixed presence session identifier is nonzero"), + Digest::new(HashAlgorithm::Blake3_256, [0x51; 32]), + checkpoint_id, + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(301_000), + descriptor.application_signing_key(), + Extensions::default(), + ) + .expect("the fixed presence challenge must have a bounded lifetime"); + let signature = application_secret.sign( + &challenge + .signing_bytes() + .expect("the presence challenge must produce signing bytes"), + ); + let proof = PresenceProof::new( + challenge.clone(), + ProtocolSignature::ed25519(signature.to_bytes()), + ) + .expect("the fixed presence proof must be bounded"); + assert_eq!( + verify_presence_proof( + &proof, + &challenge, + Timestamp::from_unix_millis(1_000), + &view, + ) + .expect("the fixed presence proof must verify"), + proof + .proof_id() + .expect("the fixed presence proof must derive an identifier"), + "presence verification must return the complete proof identifier" + ); + + if reject { + let substituted = DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + PresenceVerifierChallenge::new([0x32; 32]) + .expect("the substituted presence challenge is nonzero"), + challenge.session_id(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + challenge.issued_at(), + challenge.expires_at(), + challenge.signing_key(), + Extensions::default(), + ) + .expect("the substituted presence challenge is structurally valid"); + assert!( + matches!( + verify_presence_proof( + &proof, + &substituted, + Timestamp::from_unix_millis(1_000), + &view, + ), + Err(IdentityError::InvalidRelationship { .. }) + ), + "presence verification must reject a substituted verifier challenge" + ); + } +} + +fn freshness_semantics(reject: bool) { + let checkpoint_id = typed_id::(0x21); + let context = + AuthorizationContext::new(typed_id::(0x20), Epoch::new(4), checkpoint_id); + let policy = ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()) + .expect("the fixed local-only provider policy must be valid"); + let evidence = FreshnessEvidence::local_known(checkpoint_id); + let decision = evaluate_freshness( + context, + &policy, + FreshnessRequirement::latest_known(), + FreshnessRequirement::latest_known(), + &evidence, + Timestamp::from_unix_millis(1_000), + ) + .expect("latest-known evidence must verify for its exact checkpoint"); + assert_eq!( + decision.context(), + context, + "freshness evaluation must retain the exact authorization context" + ); + + if reject { + let substituted = FreshnessEvidence::local_known(typed_id::(0x22)); + assert!( + evaluate_freshness( + context, + &policy, + FreshnessRequirement::latest_known(), + FreshnessRequirement::latest_known(), + &substituted, + Timestamp::from_unix_millis(1_000), + ) + .is_err(), + "freshness evaluation must reject a substituted checkpoint" + ); + } +} + +fn operational_effect_semantics(reject: bool) { + let genesis = AccountGenesis::from_canonical_bytes(include_bytes!( + "../../protocols/krikos-identity/tests/vectors/account-genesis.bin" + )) + .expect("the reviewed account genesis fixture must remain canonical"); + let event = AuthorizedEvent::from_canonical_bytes(include_bytes!( + "../../protocols/krikos-identity/tests/vectors/authorized-event.bin" + )) + .expect("the reviewed authorized event fixture must remain canonical"); + let account_id = genesis + .account_id() + .expect("the reviewed account genesis must derive an identifier"); + let account_store = MemoryAccountStore::new(); + let initial = block_on(account_store.create_account(genesis)) + .expect("the fixed account must be created once in its private store"); + let committed = block_on(account_store.commit_event(initial.revision().clone(), event)) + .expect("the reviewed event must commit against its matching genesis"); + let lease_id = LeaseId::new([0x51; 16]).expect("the fixed effect lease is nonzero"); + let claimed = block_on( + account_store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(200), + Timestamp::from_unix_millis(250), + lease_id, + 8, + ) + .expect("the fixed effect claim is time-ordered and bounded"), + ), + ) + .expect("the fixed account effects must be claimable"); + let notification = claimed + .into_iter() + .find(|effect| { + matches!( + effect.effect(), + ProjectionEffect::NotifyAccountChanged { .. } + ) + }) + .expect("the committed account event must request one change notification"); + assert_eq!( + committed.snapshot().state().account_id(), + account_id, + "the committed event must retain the genesis account identity" + ); + + let journal = OperationalEffectJournal::new(MemoryOperationalEffectStore::new()); + assert_eq!( + journal + .begin(¬ification, Timestamp::from_unix_millis(201)) + .expect("the claimed notification effect must begin journaling") + .phase(), + OperationalEffectPhase::Claimed, + "a new operational journal must begin in the claimed phase" + ); + assert_eq!( + journal + .record_peers_notified(notification.id(), Timestamp::from_unix_millis(202)) + .expect("the notification effect must record peer completion") + .phase(), + OperationalEffectPhase::PeersNotified, + "peer notification must advance the operational phase" + ); + assert_eq!( + journal + .record_completed(notification.id(), Timestamp::from_unix_millis(203)) + .expect("a peer-notified effect must complete") + .phase(), + OperationalEffectPhase::Completed, + "the valid notification journal must reach completion" + ); + + if reject { + let rejected_journal = OperationalEffectJournal::new(MemoryOperationalEffectStore::new()); + rejected_journal + .begin(¬ification, Timestamp::from_unix_millis(201)) + .expect("the rejected-path journal must begin from the same valid claim"); + assert!( + rejected_journal + .record_completed(notification.id(), Timestamp::from_unix_millis(202)) + .is_err(), + "a notification effect must reject completion before peers are notified" + ); + } +} + +struct AllowProviderAdmission; + +impl ProviderAdmissionControl for AllowProviderAdmission { + fn check( + &self, + _admission: ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +struct SemanticProviderSigner(SecretKey); + +impl ProviderHeadSigner for SemanticProviderSigner { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +fn semantic_checkpoint_bundle() -> ProviderCheckpointBundle { + let genesis = AccountGenesis::from_canonical_bytes(include_bytes!( + "../../protocols/krikos-identity/tests/vectors/account-genesis.bin" + )) + .expect("the reviewed account genesis fixture must remain canonical"); + let event = AuthorizedEvent::from_canonical_bytes(include_bytes!( + "../../protocols/krikos-identity/tests/vectors/authorized-event.bin" + )) + .expect("the reviewed authorized event fixture must remain canonical"); + let checkpoint = SignedCheckpoint::from_canonical_bytes(include_bytes!( + "../../protocols/krikos-identity/tests/vectors/checkpoint-direct.bin" + )) + .expect("the reviewed signed checkpoint fixture must remain canonical"); + build_provider_checkpoint_bundle_from_genesis(&genesis, &[event], &checkpoint, None) + .expect("the reviewed genesis, event, and checkpoint must form one provider bundle") +} + +fn semantic_recovery_export(generation: ProviderGenerationExport) -> ProviderRecoveryExport { + let audit = MemoryProviderAuditStore::new(generation.provider().clone(), generation.log_id()); + let auditor = DurableProviderAuditor::new(audit.clone()); + if let Some(head) = generation.latest_head() { + auditor + .observe(head.clone(), None) + .expect("the authenticated provider head must enter its audit journal"); + } + ProviderRecoveryExport::new( + generation, + audit + .snapshot() + .expect("the semantic provider audit snapshot must be durable"), + ) + .expect("the generation and audit snapshot must bind exactly") +} + +fn provider_retention_semantics(reject: bool) { + let signer = SemanticProviderSigner(SecretKey::from_bytes(&[0x71; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()) + .expect("the fixed provider signing key is valid"), + Extensions::default(), + ) + .expect("the fixed provider descriptor must be valid"); + let log_id = typed_id::(0x72); + let store = MemoryProviderStore::new(provider, log_id, ProviderKeyVersion::GENESIS) + .expect("the fixed provider generation must open"); + let bundle = semantic_checkpoint_bundle(); + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission) + .expect("the fixed provider admission must be bounded"); + let receipt = store + .append( + authorize_provider_append(admission, request, &AllowProviderAdmission) + .expect("the verified provider admission must yield an opaque permit"), + Timestamp::from_unix_millis(1_000), + &signer, + ) + .expect("the fixed provider checkpoint must append atomically"); + assert_eq!(receipt.leaf_index(), 0); + let route = store + .generation_route() + .expect("the fixed provider route must be exact"); + let recovery = semantic_recovery_export( + store + .export_generation() + .expect("the active generation must export"), + ); + let inventory = derive_provider_retention_inventory(&recovery) + .expect("the authenticated generation must derive mandatory retention"); + let authorization = verify_provider_compaction(&recovery, &recovery, &inventory) + .expect("an exact full archive must authorize local sealing"); + store + .seal_after_verified_mirror(&authorization, &recovery, &inventory) + .expect("the exact archive must seal the active generation"); + + let account_id = bundle + .verified_checkpoint() + .checkpoint() + .body() + .account_id(); + let retained = store + .latest_retained_checkpoint_evidence(account_id) + .expect("sealed current checkpoint evidence must remain queryable") + .expect("the fixed account must retain its current checkpoint evidence"); + let reconstructed = build_provider_checkpoint_bundle_from_genesis( + retained + .genesis() + .expect("the fixed retained checkpoint must retain its genesis anchor"), + retained.events(), + retained.checkpoint(), + retained.transition_event(), + ) + .expect("raw retained evidence must remain independently verifiable"); + assert_eq!( + reconstructed.verified_checkpoint().checkpoint_id(), + bundle.verified_checkpoint().checkpoint_id(), + "retained evidence may reconstruct proof material but not mutate the sealed generation" + ); + + let archive = MemoryProviderStore::restore_recovery(recovery.clone()) + .expect("the full recovery archive must restore read-only"); + assert_eq!( + archive + .archived_recovery_export() + .expect("the restored archive must reproduce its exact recovery export"), + recovery + ); + let mut registry = ProviderGenerationRegistry::new(); + assert_eq!( + registry + .insert(store.clone()) + .expect("the sealed generation must register under its exact route"), + route + ); + assert!( + registry.insert(archive).is_err(), + "an archive cannot replace or duplicate an existing generation route" + ); + + if reject { + let replay_admission = bundle.provider_log_admission(); + let replay_request = ProviderAdmissionRequest::for_admission(&replay_admission) + .expect("the replay admission remains structurally bounded"); + assert_eq!( + store.append( + authorize_provider_append( + replay_admission, + replay_request, + &AllowProviderAdmission, + ) + .expect("the replay remains a verified opaque admission"), + Timestamp::from_unix_millis(1_001), + &signer, + ), + Err(IdentityError::ProviderArchiveRequired), + "raw retained evidence must not authorize a write to a sealed generation" + ); + } +} + +fn run_semantic_selector(selector: u8, payload: &[u8]) { + let reject = reject_relationship(payload); + match selector { + 53 => guardian_authority_semantics(reject), + 54 => social_semantics(reject), + 55 => name_semantics(reject), + 56 => private_metadata_semantics(reject), + 57 => portable_credential_semantics(reject), + 58 => application_event_semantics(reject), + 59 => presence_semantics(reject), + 60 => freshness_semantics(reject), + 61 => operational_effect_semantics(reject), + 62 => provider_retention_semantics(reject), + _ => {} + } +} + +fuzz_target!(|input: &[u8]| { + let Some((&selector, payload)) = input.split_first() else { + return; + }; + if payload.len() > MAX_SEMANTICS_PAYLOAD_BYTES { + return; + } + + let decoder_count = IDENTITY_SEMANTICS_TYPE_NAMES.len(); + assert_eq!( + decoder_count, + IDENTITY_SEMANTICS_DECODERS.len(), + "identity semantics names and decoder registry must remain aligned" + ); + match usize::from(selector) { + decoder_index @ 0..=52 => { + let Some(decoder) = IDENTITY_SEMANTICS_DECODERS.get(decoder_index) else { + return; + }; + decoder(payload); + } + 53..=62 => run_semantic_selector(selector, payload), + _ => {} + } +}); diff --git a/fuzz/fuzz_targets/identity_state.rs b/fuzz/fuzz_targets/identity_state.rs new file mode 100644 index 00000000000..a255ce12b61 --- /dev/null +++ b/fuzz/fuzz_targets/identity_state.rs @@ -0,0 +1,293 @@ +#![no_main] + +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, AdmissionEvidence, AlgorithmSignature, + ApplyDisposition, CanonicalWire, CheckpointId, ControlPolicy, ControllerApprovalBody, + ControllerApprovals, ControllerClass, ControllerDescriptor, ControllerKeyId, ControllerScope, + ControllerSelector, ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, + DelayEvidence, Digest, DurationMillis, Epoch, EventBody, EventPredecessors, Extensions, + FreshnessEvidence, FreshnessRequirement, HashAlgorithm, OperationKind, PolicyRule, + ProjectionLifecycle, ProviderPolicy, ProviderPolicyVersion, RecoveryAuthority, RecoveryPolicy, + RecoveryPolicyVersion, RequiredWeight, Sequence, SignedControllerApproval, SigningPublicKey, + Timestamp, +}; +use libfuzzer_sys::fuzz_target; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().expect("digest encodes")) + .expect("typed digest ID decodes") +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).expect("valid key"), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).expect("nonzero weight"), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .expect("valid controller") +} + +fn rule(operation: OperationKind) -> PolicyRule { + PolicyRule::new( + operation, + RequiredWeight::new(1).expect("nonzero weight"), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .expect("valid rule") +} + +fn fixture() -> (AccountState, SecretKey) { + let secret = SecretKey::from_bytes(&[0x31; 32]); + let policy = ControlPolicy::new( + vec![ + rule(OperationKind::AddController), + rule(OperationKind::ChangeProviderPolicy), + ], + Extensions::default(), + ) + .expect("valid policy"); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).expect("nonzero weight"), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .expect("valid recovery policy"); + let genesis = AccountGenesis::new( + [0x31; 32], + Timestamp::from_unix_millis(1), + policy, + vec![controller(&secret)], + recovery, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()) + .expect("valid provider policy"), + Extensions::default(), + ) + .expect("valid genesis"); + ( + AccountState::from_genesis(&genesis).expect("genesis projects"), + secret, + ) +} + +fn event( + state: &AccountState, + operation: AccountOperation, + resulting_epoch: Epoch, + nonce: u8, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).expect("bounded heads") + }; + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().expect("bounded sequence"), + resulting_epoch, + predecessors, + operation, + Timestamp::from_unix_millis(u64::from(nonce)), + [nonce.max(1); 16], + Extensions::default(), + ) + .expect("valid event body"); + let checkpoint_id = typed_id::(0x44); + let evidence = AdmissionEvidence::new( + body.proposal_id().expect("proposal ID"), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .expect("valid admission evidence"); + let event_id = evidence + .event_id_for_body(&body) + .expect("admitted event ID"); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).expect("valid key"); + let controller_id = state + .active_controllers() + .iter() + .find(|projected| projected.signing_key() == signing_key) + .expect("signer is active") + .id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + event_id, + evidence + .admission_evidence_id() + .expect("admission evidence ID"), + Extensions::default(), + ) + .expect("valid approval body"); + let signature = signer.sign( + &approval_body + .to_canonical_bytes() + .expect("approval body encodes"), + ); + let approval = SignedControllerApproval::new( + approval_body, + vec![krikos_identity::KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .expect("v1 suite") + .crypto_suite_id() + .expect("suite ID"), + ControllerKeyId::for_signing_key(&signing_key).expect("controller key ID"), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).expect("valid signature"), + )], + ) + .expect("valid approval"); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).expect("approval set"), + ) + .expect("authorized event") +} + +fn policy_change( + state: &AccountState, + signer: &SecretKey, + version: u64, + nonce: u8, +) -> krikos_identity::AuthorizedEvent { + event( + state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(version), Extensions::default()) + .expect("valid provider version"), + ), + state.epoch().checked_next().expect("bounded epoch"), + nonce.max(1), + signer, + ) +} + +fn check_linear_model(input: &[u8]) { + let (mut state, signer) = fixture(); + let steps = input.len().clamp(1, 16); + for index in 0..steps { + let version = u64::try_from(index + 1).expect("small index"); + let nonce = input.get(index).copied().unwrap_or(1).max(1); + let next = policy_change(&state, &signer, version, nonce); + let before_sequence = state.sequence(); + let before_epoch = state.epoch(); + state.validate_and_apply(&next).expect("valid linear event"); + assert_eq!(state.sequence(), before_sequence.checked_next().unwrap()); + assert_eq!(state.epoch(), before_epoch.checked_next().unwrap()); + assert_eq!(state.sequence().get(), version); + assert_eq!(state.epoch().get(), version); + } + + let replay = policy_change( + &fixture().0, + &signer, + 1, + input.first().copied().unwrap_or(1).max(1), + ); + let (mut replay_state, _) = fixture(); + replay_state + .validate_and_apply(&replay) + .expect("first application succeeds"); + let applied = replay_state.clone(); + assert_eq!( + replay_state + .validate_and_apply(&replay) + .expect("replay validates") + .disposition(), + ApplyDisposition::Replay + ); + assert_eq!(replay_state, applied); + + let invalid_version = u64::try_from(steps + 1).expect("small step count"); + let invalid = event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only( + ProviderPolicyVersion::new(invalid_version), + Extensions::default(), + ) + .expect("valid provider version"), + ), + state.epoch(), + 0xfd, + &signer, + ); + let before_invalid = state.clone(); + assert!(state.validate_and_apply(&invalid).is_err()); + assert_eq!(state, before_invalid); +} + +fn check_branch_convergence(input: &[u8]) { + let (base, signer) = fixture(); + let left_secret = SecretKey::from_bytes(&[0x32; 32]); + let right_secret = SecretKey::from_bytes(&[0x33; 32]); + let seed = input.first().copied().unwrap_or(7).max(1); + let left = event( + &base, + AccountOperation::AddController(controller(&left_secret)), + Epoch::new(1), + seed, + &signer, + ); + let mut right_nonce = seed.wrapping_add(1); + if right_nonce == 0 { + right_nonce = 1; + } + let right = event( + &base, + AccountOperation::AddController(controller(&right_secret)), + Epoch::new(1), + right_nonce, + &signer, + ); + let mut left_projection = base.clone(); + left_projection + .validate_and_apply(&left) + .expect("left applies"); + let descendant = policy_change(&left_projection, &signer, 1, seed.wrapping_add(2).max(1)); + + let mut late_conflict = base.clone(); + late_conflict + .validate_and_apply(&left) + .expect("left applies"); + late_conflict + .validate_and_apply(&descendant) + .expect("descendant applies"); + late_conflict + .validate_and_apply(&right) + .expect("late conflict opens fork"); + + let mut fork_first = base; + fork_first.validate_and_apply(&left).expect("left applies"); + fork_first + .validate_and_apply(&right) + .expect("right opens fork"); + fork_first + .validate_and_apply(&descendant) + .expect("branch descendant applies"); + assert_eq!(fork_first, late_conflict); + assert_eq!(fork_first.lifecycle(), ProjectionLifecycle::Forked); + assert_eq!(fork_first.sequence(), Sequence::new(2)); +} + +fuzz_target!(|input: &[u8]| { + if input.len() > 64 { + return; + } + check_linear_model(input); + check_branch_convergence(input); +}); diff --git a/fuzz/fuzz_targets/identity_sync.rs b/fuzz/fuzz_targets/identity_sync.rs new file mode 100644 index 00000000000..8776b248b5e --- /dev/null +++ b/fuzz/fuzz_targets/identity_sync.rs @@ -0,0 +1,69 @@ +#![no_main] + +use krikos_identity::{ + CanonicalWire, SyncCursor, SyncFrame, SyncRequest, SyncResponse, + net::{ + AuthorizedCheckpointRequest, AuthorizedProposalRequest, AuthorizedSyncRequest, + EndpointAuthorizationRequest, IdentityProtocolAck, IdentityProtocolReply, + }, +}; +use libfuzzer_sys::fuzz_target; + +const MAX_FUZZ_INPUT_BYTES: usize = 4 * 1024 * 1024 + 1; + +fuzz_target!(|input: &[u8]| { + if input.is_empty() || input.len() > MAX_FUZZ_INPUT_BYTES { + return; + } + let Some((selector, bytes)) = input.split_first() else { + return; + }; + match *selector { + 0 => { + let _ = SyncRequest::from_canonical_bytes(bytes); + } + 1 => { + let _ = SyncFrame::from_canonical_bytes(bytes); + } + 2 => { + let _ = SyncCursor::from_canonical_bytes(bytes); + } + 3 => { + let _ = SyncResponse::from_canonical_bytes(bytes); + } + 4 => { + let _ = EndpointAuthorizationRequest::from_canonical_bytes(bytes); + } + 5 => { + if let Ok(request) = AuthorizedSyncRequest::from_canonical_bytes(bytes) { + assert_eq!( + request.authorization().account_id(), + request.request().account_id() + ); + } + } + 6 => { + if let Ok(request) = AuthorizedProposalRequest::from_canonical_bytes(bytes) { + assert_eq!( + request.authorization().account_id(), + request.proposal().account_id() + ); + } + } + 7 => { + if let Ok(request) = AuthorizedCheckpointRequest::from_canonical_bytes(bytes) { + assert_eq!( + request.authorization().account_id(), + request.checkpoint().body().account_id() + ); + } + } + 8 => { + let _ = IdentityProtocolAck::from_canonical_bytes(bytes); + } + 9 => { + let _ = IdentityProtocolReply::from_canonical_bytes(bytes); + } + _ => return, + } +}); diff --git a/krikos-base/Cargo.toml b/krikos-base/Cargo.toml index ddde582fbf5..ce85e5d398f 100644 --- a/krikos-base/Cargo.toml +++ b/krikos-base/Cargo.toml @@ -43,18 +43,18 @@ serde_test = "1" [features] default = ["relay"] -key = [ +key = ["os-rng"] +key-types = [ "dep:curve25519-dalek", "dep:ed25519-dalek", "dep:url", "dep:derive_more", "dep:data-encoding", "dep:data-encoding-macro", - "dep:rand", - "dep:getrandom", "dep:zeroize", "relay", ] +os-rng = ["key-types", "dep:rand", "dep:getrandom"] relay = [ "dep:url", "dep:derive_more", diff --git a/krikos-base/README.md b/krikos-base/README.md index 403fde02fdf..9fde1cde611 100644 --- a/krikos-base/README.md +++ b/krikos-base/README.md @@ -12,6 +12,11 @@ Krikos crates: keys, addresses, and other foundations that Most applications should depend on [`krikos`](../krikos), not on this crate directly. +The deterministic `key-types` feature exposes key, signature, endpoint-identifier, and address +types without an operating-system randomness dependency. `os-rng` adds the +`SecretKey::generate` convenience API, while the existing `key` feature remains a compatibility +aggregate for `key-types` plus `os-rng`. + ## Documentation See the [root README](../README.md) for what Krikos is, and diff --git a/krikos-base/src/endpoint_addr.rs b/krikos-base/src/endpoint_addr.rs index df7dc990a46..bd042ff8bde 100644 --- a/krikos-base/src/endpoint_addr.rs +++ b/krikos-base/src/endpoint_addr.rs @@ -867,7 +867,7 @@ mod tests { #[test] #[allow(deprecated)] // Constructs an old oversized value to test bounded deserialization. fn endpoint_addr_deserialization_rejects_excessive_address_count() { - let key = crate::SecretKey::generate().public(); + let key = crate::SecretKey::from_bytes(&[0x41; 32]).public(); let addrs = (0..35) .map(|port| TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 10_000 + port)))); let legacy = EndpointAddr::from_parts(key, addrs); @@ -881,7 +881,7 @@ mod tests { #[test] fn public_field_mutation_is_detected_by_validation() { - let key = crate::SecretKey::generate().public(); + let key = crate::SecretKey::from_bytes(&[0x42; 32]).public(); let mut addr = EndpointAddr::new(key); for port in 0..=MAX_ENDPOINT_ADDRS { let port = u16::try_from(port).expect("test address count fits in u16"); @@ -901,7 +901,7 @@ mod tests { assert!(CustomAddr::try_from_parts(1, &[0_u8; MAX_CUSTOM_ADDR_BYTES]).is_ok()); assert!(CustomAddr::try_from_parts(1, &[0_u8; MAX_CUSTOM_ADDR_BYTES + 1]).is_err()); - let key = crate::SecretKey::generate().public(); + let key = crate::SecretKey::from_bytes(&[0x43; 32]).public(); let maximum_count = (0..MAX_ENDPOINT_ADDRS).map(|port| { let port = u16::try_from(port).expect("test address count fits in u16"); TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 20_000 + port))) diff --git a/krikos-base/src/key.rs b/krikos-base/src/key.rs index f4175f130c1..56459687313 100644 --- a/krikos-base/src/key.rs +++ b/krikos-base/src/key.rs @@ -301,7 +301,7 @@ impl SecretKey { PublicKey(CompressedEdwardsY(key)) } - /// Generate a new [`SecretKey`] with a randomness generator. + /// Generate a new [`SecretKey`] using operating-system entropy. /// /// This uses the default random number generator from the `rand` crate. /// If you want to customize how the randomness is generated, use @@ -315,6 +315,8 @@ impl SecretKey { /// // Use it to generate the 32 bytes that make up a secret key. /// let secret_key = SecretKey::from_bytes(&rng.random()); /// ``` + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] pub fn generate() -> Self { Self::from_bytes(&rand::random()) } @@ -498,7 +500,6 @@ fn decode_base32_hex(s: &str) -> Result<[u8; 32], KeyParsingError> { #[cfg(test)] mod tests { use data_encoding::HEXLOWER; - use rand::{RngExt, SeedableRng}; use super::*; @@ -532,8 +533,7 @@ mod tests { #[test] fn test_from_str() { - let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0u64); - let key = SecretKey::from_bytes(&rng.random()); + let key = SecretKey::from_bytes(&[0x41; 32]); assert_eq!( SecretKey::from_str(&HEXLOWER.encode(&key.to_bytes())) .unwrap() @@ -555,7 +555,7 @@ mod tests { #[test] fn signature_postcard() { - let key = SecretKey::generate(); + let key = SecretKey::from_bytes(&[0x42; 32]); let signature = key.sign(b"hello world"); let bytes = postcard::to_stdvec(&signature).unwrap(); let signature2: Signature = postcard::from_bytes(&bytes).unwrap(); diff --git a/krikos-base/src/lib.rs b/krikos-base/src/lib.rs index fef9e845b31..dad40aa3668 100644 --- a/krikos-base/src/lib.rs +++ b/krikos-base/src/lib.rs @@ -1,22 +1,29 @@ -//! Base types and utilities for Krikos +//! Base types and utilities for Krikos. +//! +//! # Features +//! +//! - `key-types` enables deterministic key, signature, endpoint-identifier, and address types. +//! - `os-rng` adds the `SecretKey::generate` operating-system entropy convenience API. +//! - `key` is a backward-compatible aggregate for `key-types` plus `os-rng`. +//! - `relay` enables relay URL types. #![forbid(unsafe_code)] #![cfg_attr(krikos_docsrs, feature(doc_cfg))] #![deny(missing_docs, rustdoc::broken_intra_doc_links, unreachable_pub)] #![cfg_attr(not(test), deny(clippy::unwrap_used))] -#[cfg(feature = "key")] +#[cfg(feature = "key-types")] mod endpoint_addr; -#[cfg(feature = "key")] +#[cfg(feature = "key-types")] mod key; #[cfg(feature = "relay")] mod relay_url; -#[cfg(feature = "key")] +#[cfg(feature = "key-types")] pub use self::endpoint_addr::{ AddressLimitError, AddressLimits, CustomAddr, EndpointAddr, MAX_CUSTOM_ADDR_BYTES, MAX_ENDPOINT_ADDR_BYTES, MAX_ENDPOINT_ADDRS, MAX_RELAY_URL_BYTES, TransportAddr, }; -#[cfg(feature = "key")] +#[cfg(feature = "key-types")] pub use self::key::{ EndpointId, KeyParsingError, PublicKey, SecretKey, Signature, SignatureError, SignatureParsingError, diff --git a/krikos-relay/src/relay_map.rs b/krikos-relay/src/relay_map.rs index dc3bb3373bb..fc417a2fbc3 100644 --- a/krikos-relay/src/relay_map.rs +++ b/krikos-relay/src/relay_map.rs @@ -13,7 +13,7 @@ use crate::defaults::DEFAULT_RELAY_QUIC_PORT; /// List of relay server configurations to be used in an krikos endpoint. /// -/// A [`RelayMap`] can be constructed from an iterator of [`RelayConfig`] or [`RelayUrl]`, +/// A [`RelayMap`] can be constructed from an iterator of [`RelayConfig`] or [`RelayUrl`], /// or by creating an empty relay map with [`RelayMap::empty`] and then adding entries with /// [`RelayMap::insert`]. /// diff --git a/krikos-sim/Cargo.lock b/krikos-sim/Cargo.lock index 271a7f1b57a..977ffeabf11 100644 --- a/krikos-sim/Cargo.lock +++ b/krikos-sim/Cargo.lock @@ -12,6 +12,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -19,7 +29,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -29,9 +39,9 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", + "aead 0.5.2", "aes", - "cipher", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -141,6 +151,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -321,6 +343,15 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -392,6 +423,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.5" @@ -452,6 +492,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -499,8 +545,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20", + "cipher 0.5.2", + "poly1305", + "zeroize", ] [[package]] @@ -549,7 +610,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -814,7 +886,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -977,6 +1049,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1180,7 +1253,7 @@ dependencies = [ "diatomic-waker", "futures-core", "pin-project-lite", - "spin", + "spin 0.10.1", ] [[package]] @@ -1396,6 +1469,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1413,6 +1495,20 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.9", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1767,6 +1863,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipconfig" version = "0.3.4" @@ -2006,6 +2111,24 @@ dependencies = [ "url", ] +[[package]] +name = "krikos-identity" +version = "1.0.0" +dependencies = [ + "argon2", + "blake3", + "chacha20poly1305", + "curve25519-dalek", + "data-encoding", + "krikos-base", + "postcard", + "rand_core 0.10.1", + "serde", + "thiserror 2.0.19", + "x25519-dalek", + "zeroize", +] + [[package]] name = "krikos-noq" version = "1.1.0-holon.1" @@ -2125,6 +2248,8 @@ dependencies = [ "criterion", "curve25519-dalek", "krikos", + "krikos-base", + "krikos-identity", "krikos-relay", "krikos-runtime", "n0-error", @@ -2133,6 +2258,7 @@ dependencies = [ "netwatch", "noq-udp", "proptest", + "rand_core 0.10.1", "rustls", "serde", "serde_json", @@ -2743,6 +2869,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -2886,6 +3023,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2895,7 +3042,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -2916,6 +3063,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", "postcard-derive", "serde", ] @@ -3080,6 +3228,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -3752,6 +3906,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spin" version = "0.10.1" @@ -4296,6 +4459,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4869,6 +5042,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" diff --git a/krikos-sim/Cargo.toml b/krikos-sim/Cargo.toml index d07facad6a4..dae333efd90 100644 --- a/krikos-sim/Cargo.toml +++ b/krikos-sim/Cargo.toml @@ -35,12 +35,15 @@ blake3 = "1.8.3" clap = { version = "4", features = ["derive"] } curve25519-dalek = { version = "5", default-features = false, features = ["zeroize"] } krikos = { version = "1.0.0", path = "../krikos", default-features = false, features = ["tls-ring"] } +krikos-base = { version = "1.0.0", path = "../krikos-base", default-features = false, features = ["key"] } +krikos-identity = { version = "1.0.0", path = "../protocols/krikos-identity", default-features = false } krikos-relay = { version = "1.0.0", path = "../krikos-relay", default-features = false, features = ["server-ring", "test-utils"] } krikos-runtime = { version = "1.0.0", path = "../krikos-runtime" } n0-future = "0.3" n0-watcher = "1.0.0" netwatch = "0.19.1" noq-udp = { version = "1.1.0", default-features = false } +rand_core = { version = "0.10", default-features = false } rustls = { version = "0.23.33", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -74,6 +77,10 @@ harness = false name = "cargo-sim" path = "src/bin/cargo-sim.rs" +[[bin]] +name = "identity-model-check" +path = "src/bin/identity-model-check.rs" + [patch.crates-io] # Local sources for the exact-version production forks until their packages are published. krikos-noq = { path = "../vendor/noq-1.1.0" } diff --git a/krikos-sim/identity-corpus/authority-lifecycle.json b/krikos-sim/identity-corpus/authority-lifecycle.json new file mode 100644 index 00000000000..88739d1da00 --- /dev/null +++ b/krikos-sim/identity-corpus/authority-lifecycle.json @@ -0,0 +1,122 @@ +{ + "schema_version": 1, + "id": "identity/authority-lifecycle", + "actions": [ + { + "id": "authorize-device-7", + "at_nanos": 0, + "action": { "kind": "authorize_device", "device": 7, "approvals": [1] } + }, + { + "id": "authorize-device-8", + "at_nanos": 1, + "action": { "kind": "authorize_device", "device": 8, "approvals": [1] } + }, + { + "id": "revoke-device-7", + "at_nanos": 2, + "action": { "kind": "revoke_device", "device": 7, "approvals": [1] } + }, + { + "id": "publish-device-revocation", + "at_nanos": 3, + "action": { "kind": "publish_revocation", "subject": "device:7" } + }, + { + "id": "change-policy", + "at_nanos": 4, + "action": { "kind": "change_policy", "required_weight": 2, "approvals": [1, 2] } + }, + { + "id": "fork-left", + "at_nanos": 5, + "action": { + "kind": "fork_proposal", + "fork": "authority-fork", + "branch": "left", + "approvals": [1, 2], + "operation": { "kind": "add_controller", "controller": 3, "weight": 1 } + } + }, + { + "id": "fork-right", + "at_nanos": 5, + "action": { + "kind": "fork_proposal", + "fork": "authority-fork", + "branch": "right", + "approvals": [1, 2], + "operation": { "kind": "change_policy", "required_weight": 2 } + } + }, + { + "id": "resolve-fork", + "at_nanos": 6, + "action": { + "kind": "resolve_fork", + "fork": "authority-fork", + "selected_branch": "right", + "approvals": [1, 2], + "revoked_controllers": [], + "revoked_devices": [] + } + }, + { + "id": "recover", + "at_nanos": 7, + "action": { + "kind": "recover", + "controllers": [ + { "controller": 9, "weight": 2 }, + { "controller": 10, "weight": 1 } + ], + "required_weight": 2 + } + }, + { + "id": "authorize-post-recovery-device", + "at_nanos": 8, + "action": { "kind": "authorize_device", "device": 11, "approvals": [9] } + }, + { + "id": "revoke-controller-10", + "at_nanos": 9, + "action": { "kind": "revoke_controller", "controller": 10, "approvals": [9] } + }, + { + "id": "publish-controller-revocation", + "at_nanos": 10, + "action": { "kind": "publish_revocation", "subject": "controller:10" } + }, + { + "id": "migration-begin", + "at_nanos": 11, + "action": { "kind": "migration", "phase": "begin", "approvals": [9] } + }, + { + "id": "migration-activate", + "at_nanos": 12, + "action": { "kind": "migration", "phase": "activate", "approvals": [9] } + }, + { + "id": "migration-complete", + "at_nanos": 13, + "action": { "kind": "migration", "phase": "complete", "approvals": [9] } + }, + { + "id": "rotate-group-key", + "at_nanos": 14, + "action": { "kind": "rotate_group_key", "approvals": [9] } + }, + { + "id": "offline-validation", + "at_nanos": 15, + "action": { "kind": "offline_validate" } + }, + { + "id": "social-edge", + "at_nanos": 16, + "action": { "kind": "social_relationship" } + } + ] +} diff --git a/krikos-sim/identity-corpus/manifest.json b/krikos-sim/identity-corpus/manifest.json new file mode 100644 index 00000000000..68db7ef6dfc --- /dev/null +++ b/krikos-sim/identity-corpus/manifest.json @@ -0,0 +1,19 @@ +{ + "schema_version": 2, + "entries": [ + { + "id": "identity/authority-lifecycle", + "scenario_file": "authority-lifecycle.json", + "seed": "2222222222222222222222222222222222222222222222222222222222222222", + "expectation": { "terminal": "success" }, + "reviewed": true + }, + { + "id": "identity/network-storage-provider", + "scenario_file": "network-storage-provider.json", + "seed": "1111111111111111111111111111111111111111111111111111111111111111", + "expectation": { "terminal": "success" }, + "reviewed": true + } + ] +} diff --git a/krikos-sim/identity-corpus/network-storage-provider.json b/krikos-sim/identity-corpus/network-storage-provider.json new file mode 100644 index 00000000000..99cfd3215ce --- /dev/null +++ b/krikos-sim/identity-corpus/network-storage-provider.json @@ -0,0 +1,86 @@ +{ + "schema_version": 1, + "id": "identity/network-storage-provider", + "actions": [ + { + "id": "partition", + "at_nanos": 0, + "action": { "kind": "partition" } + }, + { + "id": "delay", + "at_nanos": 1, + "action": { "kind": "delivery_fault", "fault": "delay" } + }, + { + "id": "reorder", + "at_nanos": 1, + "action": { "kind": "delivery_fault", "fault": "reorder" } + }, + { + "id": "loss", + "at_nanos": 2, + "action": { "kind": "delivery_fault", "fault": "loss" } + }, + { + "id": "duplicate", + "at_nanos": 3, + "action": { "kind": "delivery_fault", "fault": "duplicate" } + }, + { + "id": "heal", + "at_nanos": 4, + "action": { "kind": "heal" } + }, + { + "id": "authorize-device", + "at_nanos": 5, + "action": { "kind": "authorize_device", "device": 40, "approvals": [1] } + }, + { + "id": "crash", + "at_nanos": 6, + "action": { "kind": "crash", "replica": 1 } + }, + { + "id": "reopen-storage-loss", + "at_nanos": 7, + "action": { "kind": "reopen", "replica": 1, "storage_loss": true } + }, + { + "id": "provider-outage", + "at_nanos": 8, + "action": { "kind": "provider_outage" } + }, + { + "id": "outage-sensitive-probe", + "at_nanos": 9, + "action": { "kind": "sensitive_probe" } + }, + { + "id": "provider-equivocation", + "at_nanos": 10, + "action": { "kind": "provider_equivocation" } + }, + { + "id": "equivocation-sensitive-probe", + "at_nanos": 11, + "action": { "kind": "sensitive_probe" } + }, + { + "id": "provider-restore", + "at_nanos": 12, + "action": { "kind": "provider_restore" } + }, + { + "id": "offline-validation", + "at_nanos": 13, + "action": { "kind": "offline_validate" } + }, + { + "id": "social-edge", + "at_nanos": 14, + "action": { "kind": "social_relationship" } + } + ] +} diff --git a/krikos-sim/src/bin/identity-model-check.rs b/krikos-sim/src/bin/identity-model-check.rs new file mode 100644 index 00000000000..2af0fe90bbd --- /dev/null +++ b/krikos-sim/src/bin/identity-model-check.rs @@ -0,0 +1,13 @@ +//! Hermetic bounded account-control model checker command. + +fn main() { + match krikos_sim::identity::check_account_control_model() + .and_then(|report| report.to_canonical_json()) + { + Ok(bytes) => print!("{}", String::from_utf8_lossy(&bytes)), + Err(error) => { + eprintln!("identity model check failed: {error}"); + std::process::exit(1); + } + } +} diff --git a/krikos-sim/src/cli/identity.rs b/krikos-sim/src/cli/identity.rs new file mode 100644 index 00000000000..5e433956e00 --- /dev/null +++ b/krikos-sim/src/cli/identity.rs @@ -0,0 +1,358 @@ +use super::*; + +pub(super) fn execute_identity(operation: IdentityCommand) -> Result<(), CliError> { + match operation { + IdentityCommand::Run { + scenario, + seed, + artifacts, + max_minimization_attempts, + } => execute_run(&scenario, &seed, &artifacts, max_minimization_attempts), + IdentityCommand::Replay { manifest } => execute_replay(&manifest), + IdentityCommand::CorpusTest { path } => { + let reports = crate::identity::IdentityCorpus::load(&path) + .and_then(|corpus| corpus.test()) + .map_err(|error| CliError::Runner(error.to_string()))?; + print_json(&reports) + } + IdentityCommand::ModelCheck => { + let bytes = crate::identity::check_account_control_model() + .and_then(|report| report.to_canonical_json()) + .map_err(|error| CliError::Runner(error.to_string()))?; + print!("{}", String::from_utf8_lossy(&bytes)); + Ok(()) + } + IdentityCommand::Differential { seed } => { + let report = crate::identity::run_differential_history(parse_seed(&seed)?) + .map_err(|error| CliError::Runner(error.to_string()))?; + print_json(&report) + } + IdentityCommand::PromotionCandidate { + manifest, + output, + issue, + } => execute_promotion_candidate(&manifest, &output, issue), + } +} + +fn execute_run( + scenario_path: &Path, + seed_text: &str, + artifacts: &Path, + max_minimization_attempts: u64, +) -> Result<(), CliError> { + let scenario = crate::identity::IdentityScenario::from_json( + &read_file(scenario_path).map_err(CliError::Io)?, + ) + .map_err(|error| CliError::Runner(error.to_string()))?; + let seed = parse_seed(seed_text)?; + let artifact_root = absolutize(artifacts)?; + let workspace = workspace_root()?; + let source = source_identity(&workspace, Some(&artifact_root))?; + let lockfile_digest = digest_file(&workspace.join("Cargo.lock"))?; + let canonical_scenario = scenario + .to_canonical_json() + .map_err(|error| CliError::Runner(error.to_string()))?; + let outcome = crate::identity::IdentityScenarioRunner::run_detailed(&scenario, seed) + .map_err(|error| CliError::Runner(error.to_string()))?; + let original_first = match outcome { + crate::identity::IdentityRunOutcome::Success(record) => { + let manifest = identity_manifest( + source, + seed_text, + &scenario, + &canonical_scenario, + lockfile_digest, + ); + let store = ArtifactStore::new(&artifact_root)?; + crate::identity::IdentityArtifactBundle { + scenario: &scenario, + manifest: &manifest, + record: &record, + } + .write(&store) + .map_err(|error| CliError::Runner(error.to_string()))?; + println!( + "status=success scenario={} manifest={}", + scenario.id(), + store.root().join("manifest.json").display() + ); + return Ok(()); + } + crate::identity::IdentityRunOutcome::ExpectedRejection(record) => { + let manifest = identity_manifest( + source, + seed_text, + &scenario, + &canonical_scenario, + lockfile_digest, + ); + let store = ArtifactStore::new(&artifact_root)?; + crate::identity::IdentityRejectionArtifactBundle { + scenario: &scenario, + manifest: &manifest, + record: &record, + } + .write(&store) + .map_err(|error| CliError::Runner(error.to_string()))?; + println!( + "status=expected_rejection terminal=expected_rejection scenario={} class={} rejection={} manifest={}", + scenario.id(), + record.evidence.class.as_str(), + record.evidence.rejection.as_str(), + store.root().join("manifest.json").display() + ); + return Ok(()); + } + crate::identity::IdentityRunOutcome::Failed(failure) => failure, + }; + + let original_second = require_failed_run( + crate::identity::IdentityScenarioRunner::run_detailed(&scenario, seed) + .map_err(|error| CliError::Runner(error.to_string()))?, + "original confirmation unexpectedly succeeded", + )?; + let signature = original_first + .signature() + .map_err(|error| CliError::Runner(error.to_string()))?; + if original_second + .signature() + .map_err(|error| CliError::Runner(error.to_string()))? + != signature + || original_first != original_second + { + return Err(CliError::Runner( + "identity failure did not reproduce byte-exactly under the same seed".to_owned(), + )); + } + let mut evaluator = |candidate: &crate::identity::IdentityScenario| { + match crate::identity::IdentityScenarioRunner::run_detailed(candidate, seed) + .map_err(|error| error.to_string())? + { + crate::identity::IdentityRunOutcome::Success(_) => Ok(None), + crate::identity::IdentityRunOutcome::ExpectedRejection(_) => Ok(None), + crate::identity::IdentityRunOutcome::Failed(failure) => failure + .signature() + .map(Some) + .map_err(|error| error.to_string()), + } + }; + let minimized = crate::identity::IdentityMinimizer::new(max_minimization_attempts) + .and_then(|minimizer| minimizer.minimize(scenario.clone(), signature, &mut evaluator)) + .map_err(|error| CliError::Runner(error.to_string()))?; + let minimized_first = require_failed_run( + crate::identity::IdentityScenarioRunner::run_detailed(&minimized.scenario, seed) + .map_err(|error| CliError::Runner(error.to_string()))?, + "minimized failure unexpectedly succeeded", + )?; + let minimized_second = require_failed_run( + crate::identity::IdentityScenarioRunner::run_detailed(&minimized.scenario, seed) + .map_err(|error| CliError::Runner(error.to_string()))?, + "minimized replay confirmation unexpectedly succeeded", + )?; + let confirmation = crate::identity::IdentityFailureConfirmation::new( + &scenario, + &minimized.scenario, + &original_first, + &original_second, + &minimized_first, + &minimized_second, + ) + .map_err(|error| CliError::Runner(error.to_string()))?; + let canonical_minimized = minimized + .scenario + .to_canonical_json() + .map_err(|error| CliError::Runner(error.to_string()))?; + let manifest = identity_manifest( + source, + seed_text, + &minimized.scenario, + &canonical_minimized, + lockfile_digest, + ); + let store = ArtifactStore::new(&artifact_root)?; + crate::identity::IdentityFailureArtifactBundle { + original: &scenario, + minimized: &minimized, + manifest: &manifest, + original_failure: &original_second, + minimized_failure: &minimized_second, + confirmation: &confirmation, + } + .write(&store) + .map_err(|error| CliError::Runner(error.to_string()))?; + Err(CliError::Runner(format!( + "confirmed identity failure recorded: scenario={} signature={}/{} manifest={}", + scenario.id(), + minimized.signature.class, + minimized.signature.evidence_digest, + store.root().join("manifest.json").display() + ))) +} + +fn require_failed_run( + outcome: crate::identity::IdentityRunOutcome, + success_error: &str, +) -> Result { + match outcome { + crate::identity::IdentityRunOutcome::Success(_) => { + Err(CliError::Runner(success_error.to_owned())) + } + crate::identity::IdentityRunOutcome::ExpectedRejection(_) => Err(CliError::Runner( + "identity product failure became an expected model rejection".to_owned(), + )), + crate::identity::IdentityRunOutcome::Failed(failure) => Ok(failure), + } +} + +fn execute_replay(manifest_path: &Path) -> Result<(), CliError> { + let manifest_path = std::fs::canonicalize(absolutize(manifest_path)?).map_err(CliError::Io)?; + let artifact_root = manifest_path + .parent() + .ok_or(CliError::ManifestHasNoParent)?; + let manifest = RunManifest::from_json(&read_file(&manifest_path).map_err(CliError::Io)?)?; + let scenario = crate::identity::IdentityScenario::from_json( + &read_file(artifact_root.join("scenario.json")).map_err(CliError::Io)?, + ) + .map_err(|error| CliError::Runner(error.to_string()))?; + let workspace = workspace_root()?; + let canonical_scenario = scenario + .to_canonical_json() + .map_err(|error| CliError::Runner(error.to_string()))?; + let current = ReplayIdentity { + schema_version: MANIFEST_SCHEMA_VERSION, + simulator_version: SIMULATOR_VERSION.to_owned(), + source: source_identity(&workspace, Some(artifact_root))?, + scenario_hash: blake3::hash(&canonical_scenario).to_hex().to_string(), + normalized_config: identity_config(), + features: Vec::new(), + lockfile_digest: digest_file(&workspace.join("Cargo.lock"))?, + }; + if manifest.backend != BackendCapabilities::deterministic_kernel() + || manifest.scheduling_profile != "seeded-kernel-v1" + || manifest.fault_profile != "identity-actions-v1" + || manifest.crypto_mode != crate::CryptoMode::DeterministicTest + || manifest.trace_comparison != crate::TraceComparisonMode::Raw + || manifest.determinism_grade != DeterminismGrade::FullyDeterministic + || manifest.fidelity_exceptions != ["deterministic_test_crypto"] + || !manifest.escapes.is_empty() + { + return Err(CliError::BackendIdentityMismatch); + } + let has_failure = artifact_root.join("failure-artifacts.json").is_file(); + let has_rejection = artifact_root + .join("identity-rejection-report.json") + .is_file(); + if has_failure && has_rejection { + return Err(CliError::Runner( + "identity artifact directory declares conflicting terminal classes".to_owned(), + )); + } + if has_failure { + let record = crate::identity::replay_identity_failure_artifacts(artifact_root, ¤t) + .map_err(|error| CliError::Runner(error.to_string()))?; + println!( + "status=replay_ok terminal=expected_failure scenario={} steps={} manifest={}", + scenario.id(), + record.report.steps.len(), + manifest_path.display() + ); + return Ok(()); + } + if has_rejection { + let record = crate::identity::replay_identity_rejection_artifacts(artifact_root, ¤t) + .map_err(|error| CliError::Runner(error.to_string()))?; + println!( + "status=replay_ok terminal=expected_rejection scenario={} class={} rejection={} steps={} manifest={}", + scenario.id(), + record.evidence.class.as_str(), + record.evidence.rejection.as_str(), + record.report.steps.len(), + manifest_path.display() + ); + return Ok(()); + } + let record = crate::identity::replay_identity_artifacts(artifact_root, ¤t) + .map_err(|error| CliError::Runner(error.to_string()))?; + println!( + "status=replay_ok scenario={} steps={} manifest={}", + scenario.id(), + record.report.steps.len(), + manifest_path.display() + ); + Ok(()) +} + +fn execute_promotion_candidate( + manifest_path: &Path, + output: &Path, + issue: String, +) -> Result<(), CliError> { + execute_replay(manifest_path)?; + let manifest_path = std::fs::canonicalize(absolutize(manifest_path)?).map_err(CliError::Io)?; + let failure_root = manifest_path + .parent() + .ok_or(CliError::ManifestHasNoParent)?; + if !failure_root.join("failure-artifacts.json").is_file() { + return Err(CliError::Runner( + "identity corpus promotion requires a committed failure bundle".to_owned(), + )); + } + let output = absolutize(output)?; + let store = ArtifactStore::new(&output)?; + let entry = crate::identity::write_identity_promotion_candidate(failure_root, &store, issue) + .map_err(|error| CliError::Runner(error.to_string()))?; + println!( + "status=promotion_candidate_pending_review scenario={} entry={}", + entry.id, + store.root().join("entry.json").display() + ); + Ok(()) +} + +fn identity_manifest( + source: SourceIdentity, + seed: &str, + scenario: &crate::identity::IdentityScenario, + canonical_scenario: &[u8], + lockfile_digest: String, +) -> RunManifest { + RunManifest { + schema_version: MANIFEST_SCHEMA_VERSION, + simulator_version: SIMULATOR_VERSION.to_owned(), + source, + root_seed: seed.to_owned(), + scenario_id: scenario.id().to_owned(), + scenario_hash: blake3::hash(canonical_scenario).to_hex().to_string(), + normalized_config: identity_config(), + features: Vec::new(), + wall_clock_epoch_secs: 0, + backend: BackendCapabilities::deterministic_kernel(), + budgets: RunBudgets { + max_events: 10_000, + max_virtual_time_nanos: 60_000_000_000, + max_tasks: 512, + max_packets: 1, + }, + scheduling_profile: "seeded-kernel-v1".to_owned(), + fault_profile: "identity-actions-v1".to_owned(), + lockfile_digest, + crypto_mode: crate::CryptoMode::DeterministicTest, + trace_comparison: crate::TraceComparisonMode::Raw, + fidelity_exceptions: vec!["deterministic_test_crypto".to_owned()], + determinism_grade: DeterminismGrade::FullyDeterministic, + escapes: Vec::new(), + unsafe_test_only: true, + } +} + +fn identity_config() -> BTreeMap { + BTreeMap::from([("lane".to_owned(), "identity".to_owned())]) +} + +fn print_json(value: &impl serde::Serialize) -> Result<(), CliError> { + let json = + serde_json::to_string_pretty(value).map_err(|error| CliError::Trace(error.to_string()))?; + println!("{json}"); + Ok(()) +} diff --git a/krikos-sim/src/cli/mod.rs b/krikos-sim/src/cli/mod.rs index 4b3c35f5fee..60d78152339 100644 --- a/krikos-sim/src/cli/mod.rs +++ b/krikos-sim/src/cli/mod.rs @@ -37,6 +37,7 @@ use crate::{ mod campaign; mod corpus; mod gate; +mod identity; mod parity; mod replay; mod run; @@ -46,6 +47,7 @@ mod soak; use campaign::*; use corpus::*; use gate::*; +use identity::*; use parity::*; use replay::*; use run::*; @@ -222,6 +224,11 @@ enum Command { }, /// Explain a manifest or trace artifact. Explain { artifact: PathBuf }, + /// Check deterministic identity scenarios, differential histories, and the formal model. + Identity { + #[command(subcommand)] + operation: IdentityCommand, + }, /// Export or compare backend-neutral semantic parity fixtures. Parity { #[command(subcommand)] @@ -266,6 +273,46 @@ enum ParityCommand { }, } +#[derive(Debug, Subcommand)] +enum IdentityCommand { + /// Run one strict identity scenario and write immutable replay artifacts. + Run { + scenario: PathBuf, + /// Lowercase 32-byte hexadecimal root seed. + #[arg(long)] + seed: String, + /// Fresh immutable artifact directory. + #[arg(long)] + artifacts: PathBuf, + /// Hard action-deletion attempt budget used only after a confirmed failure. + #[arg(long, default_value_t = 1_024)] + max_minimization_attempts: u64, + }, + /// Re-run an identity artifact bundle and require byte-exact report and traces. + Replay { manifest: PathBuf }, + /// Validate and replay the complete reviewed identity corpus. + CorpusTest { path: PathBuf }, + /// Run the hermetic bounded account-control checker. + ModelCheck, + /// Run one deterministic implementation/reference history. + Differential { + /// Lowercase 32-byte hexadecimal root seed. + #[arg(long)] + seed: String, + }, + /// Replay a minimized failure and stage an unreviewed permanent-corpus candidate. + PromotionCandidate { + /// Failure bundle manifest produced by `identity run`. + manifest: PathBuf, + /// Fresh directory for `scenario.json` and pending `entry.json`. + #[arg(long)] + output: PathBuf, + /// Human-review issue or audit reference recorded in promotion evidence. + #[arg(long)] + issue: String, + }, +} + /// Parses process arguments and returns a stable exit status. pub fn run(args: impl IntoIterator) -> Result<(), CliError> { let cli = match Cli::try_parse_from(args) { @@ -362,6 +409,7 @@ pub fn run(args: impl IntoIterator) -> Result<(), CliError> { } => execute_minimize(&manifest, output.as_deref(), resume, max_attempts), Command::Corpus { operation, path } => execute_corpus(&operation, path.as_deref()), Command::Explain { artifact } => execute_explain(&artifact), + Command::Identity { operation } => execute_identity(operation), Command::Parity { operation } => match operation { ParityCommand::Export { case, diff --git a/krikos-sim/src/corpus.rs b/krikos-sim/src/corpus.rs index 61486ccb692..05314ee5239 100644 --- a/krikos-sim/src/corpus.rs +++ b/krikos-sim/src/corpus.rs @@ -217,7 +217,7 @@ impl CorpusMetadata { || self.scenario_file != "scenario.json" || self.provenance.is_empty() || self.minimum_simulator_version.is_empty() - || !self.issue.as_ref().is_some_and(|issue| !issue.is_empty()) + || self.issue.as_ref().is_none_or(|issue| issue.is_empty()) { return Err(CorpusError::InvalidMetadata(self.id.clone())); } diff --git a/krikos-sim/src/identity/adapter.rs b/krikos-sim/src/identity/adapter.rs new file mode 100644 index 00000000000..c075f932045 --- /dev/null +++ b/krikos-sim/src/identity/adapter.rs @@ -0,0 +1,1710 @@ +//! Differential adapter for the production identity projection. +//! +//! This is the only identity-simulation source file allowed to depend on `krikos_identity`. + +use std::{ + collections::{BTreeMap, BTreeSet}, + convert::Infallible, +}; + +use krikos_base::SecretKey; +use krikos_identity as implementation; +use krikos_runtime::RootSeed; +use rand_core::{TryCryptoRng, TryRng}; +use serde::{Deserialize, Serialize}; + +use super::{ + AccountControlModel, ControllerId, DeviceId, DeviceLifecycle, EventId, ForkResolution, + IdentityEvent, IdentityOperation, MigrationState, ModelController, ModelPolicy, RecoveryPlan, +}; + +/// Required semantic actions exercised by every generated differential history. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DifferentialCoverage { + pub policy_change: bool, + pub controller_revocation: bool, + pub device_revocation: bool, + pub fork_and_resolution: bool, + pub recovery: bool, + pub migration: bool, + pub group_recipient_rotation: bool, +} + +/// Common public projection compared without sharing transition implementation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DifferentialSnapshot { + pub account_id: [u8; 32], + pub sequence: u64, + pub epoch: u64, + /// Normalized current head labels mapped to their complete predecessor label sets. + pub canonical_heads: BTreeMap>, + pub active_controllers: Vec, + pub revoked_controllers: Vec, + pub active_devices: Vec, + pub revoked_devices: Vec, + pub required_weight: u16, + pub forked: bool, + pub migration: MigrationState, + pub group_key_generation: u64, + pub group_key_recipients: Vec, +} + +impl DifferentialSnapshot { + /// Compare two independently observed projections and retain both exact observations. + pub fn compare( + self, + action: impl Into, + reference: Self, + ) -> Result { + let action = action.into(); + if self != reference { + return Err(DifferentialError::Divergence { + action, + implementation: Box::new(self), + reference: Box::new(reference), + }); + } + Ok(DifferentialStep { + action, + implementation: self, + reference, + }) + } +} + +/// One implementation/reference equality checkpoint. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DifferentialStep { + pub action: String, + pub implementation: DifferentialSnapshot, + pub reference: DifferentialSnapshot, +} + +/// Deterministic generated history and all differential checkpoints. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DifferentialHistoryReport { + pub root_seed: [u8; 32], + pub selected_fork_branch: String, + pub coverage: DifferentialCoverage, + pub production_evidence: DifferentialProductionEvidence, + pub steps: Vec, +} + +/// Production artifacts that prevent the comparator from substituting expected migration or +/// recipient state for observations. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DifferentialProductionEvidence { + pub migration_crypto_commitment_changed: bool, + pub new_suite_authorized: bool, + pub old_suite_rejected: bool, + pub revoked_recipient_rejected: bool, + pub group_rotation_wraps: u64, +} + +/// Runs one generated history through independent and production state machines. +pub fn run_differential_history( + seed: RootSeed, +) -> Result { + let controller_secrets = BTreeMap::from([ + (1_u16, SecretKey::from_bytes(&[1; 32])), + (2, SecretKey::from_bytes(&[2; 32])), + (3, SecretKey::from_bytes(&[3; 32])), + (9, SecretKey::from_bytes(&[9; 32])), + (10, SecretKey::from_bytes(&[10; 32])), + (19, SecretKey::from_bytes(&[19; 32])), + ]); + let provider_secret = SecretKey::from_bytes(&[99; 32]); + let initial_policy = control_policy(1, 1)?; + let recovery_policy = recovery_policy()?; + let provider_policy = provider_policy(&provider_secret)?; + let controller_one = production_controller(secret(&controller_secrets, 1)?, 1)?; + let controller_two = production_controller(secret(&controller_secrets, 2)?, 1)?; + let genesis = implementation::AccountGenesis::new( + [0x41; 32], + implementation::Timestamp::from_unix_millis(1), + initial_policy, + vec![controller_one.clone(), controller_two.clone()], + recovery_policy, + provider_policy, + implementation::Extensions::default(), + )?; + let mut production = implementation::AccountState::from_genesis(&genesis)?; + let mut controller_labels = + BTreeMap::from([(controller_one.id()?, 1_u16), (controller_two.id()?, 2_u16)]); + let mut device_labels = BTreeMap::new(); + let mut production_group_recipients = BTreeSet::new(); + let mut production_group_key_generation = 0_u64; + let mut lineage = DifferentialLineage::default(); + let mut reference = ReferenceHarness::new(*production.account_id().as_digest().as_bytes())?; + let mut steps = Vec::new(); + + compare_step( + "genesis", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let changed_policy = control_policy(1, seed.as_bytes()[1])?; + let change = authorized_event( + &production, + implementation::AccountOperation::ChangeControlPolicy(changed_policy), + production.epoch().checked_next()?, + 10, + secret(&controller_secrets, 1)?, + )?; + apply_production(&mut production, &change, "policy_change")?; + let reference_id = + reference.apply(IdentityOperation::ChangePolicy(ModelPolicy::new(1)?), &[1])?; + lineage.record(&change, reference_id)?; + compare_step( + "policy_change", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + for (device, nonce) in [(7_u16, 11_u8), (8, 12)] { + let application_secret = SecretKey::from_bytes(&[u8::try_from(device)?; 32]); + let endpoint_fill = u8::try_from(device.checked_add(32).ok_or(DifferentialError::Bounds)?)?; + let endpoint_secret = SecretKey::from_bytes(&[endpoint_fill; 32]); + let descriptor = production_device(&application_secret, device, &endpoint_secret)?; + let device_id = descriptor.id()?; + device_labels.insert(device_id, device); + let operation = implementation::AccountOperation::AuthorizeDevice( + implementation::DeviceAuthorization::new( + device_id, + descriptor, + implementation::DeviceClass::ApplicationOnly, + None, + Vec::new(), + production.epoch().checked_next()?, + implementation::Extensions::default(), + )?, + ); + let event = authorized_event( + &production, + operation, + production.epoch().checked_next()?, + nonce, + secret(&controller_secrets, 1)?, + )?; + apply_production(&mut production, &event, "authorize_device")?; + let reference_id = reference.apply( + IdentityOperation::AuthorizeDevice(DeviceId::new(device)), + &[1], + )?; + lineage.record(&event, reference_id)?; + compare_step( + &format!("authorize_device_{device}"), + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + } + + let revoked_device_id = production_device_id(&device_labels, 7)?; + let revoke_device = authorized_event( + &production, + implementation::AccountOperation::RevokeDevice(implementation::RevokeDevice::new( + revoked_device_id, + None, + implementation::Extensions::default(), + )?), + production.epoch().checked_next()?, + 13, + secret(&controller_secrets, 1)?, + )?; + apply_production(&mut production, &revoke_device, "revoke_device")?; + let reference_id = reference.apply(IdentityOperation::RevokeDevice(DeviceId::new(7)), &[1])?; + lineage.record(&revoke_device, reference_id)?; + compare_step( + "revoke_device", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let common_ancestor = *production + .heads() + .first() + .ok_or(DifferentialError::Bounds)?; + let production_fork_base = production.clone(); + let reference_fork_base = reference.position()?; + let branch_controller = production_controller(secret(&controller_secrets, 3)?, 1)?; + controller_labels.insert(branch_controller.id()?, 3); + let left = authorized_event( + &production_fork_base, + implementation::AccountOperation::AddController(branch_controller), + production_fork_base.epoch().checked_next()?, + 14, + secret(&controller_secrets, 1)?, + )?; + let right_policy = control_policy(1, seed.as_bytes()[2].wrapping_add(1))?; + let right = authorized_event( + &production_fork_base, + implementation::AccountOperation::ChangeControlPolicy(right_policy), + production_fork_base.epoch().checked_next()?, + 15, + secret(&controller_secrets, 1)?, + )?; + apply_production(&mut production, &left, "fork_left")?; + apply_production(&mut production, &right, "fork_right")?; + let left_reference = reference.fork_event( + reference_fork_base, + IdentityOperation::AddController(ModelController::new(ControllerId::new(3), 1)?), + &[1], + )?; + let right_reference = reference.fork_event( + reference_fork_base, + IdentityOperation::ChangePolicy(ModelPolicy::new(1)?), + &[1], + )?; + lineage.record(&left, left_reference)?; + lineage.record(&right, right_reference)?; + compare_step( + "fork_detected", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let select_left = seed.as_bytes()[0] & 1 == 0; + let (selected_production, selected_reference, selected_name) = if select_left { + (left.event_id()?, left_reference, "left") + } else { + (right.event_id()?, right_reference, "right") + }; + let descriptor = implementation::ForkDescriptor::try_new( + implementation::ProtocolVersion::V1, + production.account_id(), + implementation::ForkCommonAncestor::Event(common_ancestor), + production.heads().to_vec(), + implementation::Extensions::default(), + )?; + let resolution = implementation::ResolveFork::try_new( + implementation::ProtocolVersion::V1, + descriptor, + selected_production, + Vec::new(), + Vec::new(), + implementation::Extensions::default(), + )?; + let resolution_event = authorized_event( + &production, + implementation::AccountOperation::ResolveFork(resolution), + left.body().resulting_epoch().checked_next()?, + 16, + secret(&controller_secrets, 1)?, + )?; + apply_production(&mut production, &resolution_event, "resolve_fork")?; + let reference_id = reference.resolve_fork(selected_reference, &[1])?; + lineage.record(&resolution_event, reference_id)?; + compare_step( + "fork_resolved", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let replacement_nine = production_controller(secret(&controller_secrets, 9)?, 2)?; + let replacement_ten = production_controller(secret(&controller_secrets, 10)?, 1)?; + controller_labels.insert(replacement_nine.id()?, 9); + controller_labels.insert(replacement_ten.id()?, 10); + let (begin_recovery, recovery_id) = + begin_recovery_operation(&production, vec![replacement_nine, replacement_ten])?; + let begin_recovery = authorized_event( + &production, + begin_recovery, + production.epoch().checked_next()?, + 17, + secret(&controller_secrets, 1)?, + )?; + let begin_proposal_id = begin_recovery.body().proposal_id()?; + apply_production(&mut production, &begin_recovery, "begin_recovery")?; + let finalize = finalize_recovery_event( + &production, + recovery_id, + begin_proposal_id, + 18, + &provider_secret, + )?; + apply_production(&mut production, &finalize, "finalize_recovery")?; + let (begin_reference, finalize_reference) = + reference.recover(vec![(9, 2), (10, 1)], 1, &[1])?; + lineage.record(&begin_recovery, begin_reference)?; + lineage.record(&finalize, finalize_reference)?; + compare_step( + "recovery", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let post_recovery_application_secret = SecretKey::from_bytes(&[11; 32]); + let post_recovery_endpoint_secret = SecretKey::from_bytes(&[43; 32]); + let post_recovery_descriptor = production_device( + &post_recovery_application_secret, + 11, + &post_recovery_endpoint_secret, + )?; + let post_recovery_device_id = post_recovery_descriptor.id()?; + device_labels.insert(post_recovery_device_id, 11); + let authorize_post_recovery_device = authorized_event( + &production, + implementation::AccountOperation::AuthorizeDevice( + implementation::DeviceAuthorization::new( + post_recovery_device_id, + post_recovery_descriptor, + implementation::DeviceClass::ApplicationOnly, + None, + Vec::new(), + production.epoch().checked_next()?, + implementation::Extensions::default(), + )?, + ), + production.epoch().checked_next()?, + 119, + secret(&controller_secrets, 9)?, + )?; + apply_production( + &mut production, + &authorize_post_recovery_device, + "authorize_post_recovery_device", + )?; + let reference_id = + reference.apply(IdentityOperation::AuthorizeDevice(DeviceId::new(11)), &[9])?; + lineage.record(&authorize_post_recovery_device, reference_id)?; + compare_step( + "authorize_post_recovery_device", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let controller_ten_id = production_controller_id(&controller_labels, 10)?; + let remove = authorized_event( + &production, + implementation::AccountOperation::RemoveController(controller_ten_id), + production.epoch().checked_next()?, + 120, + secret(&controller_secrets, 9)?, + )?; + apply_production(&mut production, &remove, "revoke_controller")?; + let reference_id = reference.apply( + IdentityOperation::RevokeController(ControllerId::new(10)), + &[9], + )?; + lineage.record(&remove, reference_id)?; + compare_step( + "revoke_controller", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let pre_migration_crypto_state = implementation::build_checkpoint_body( + &production, + implementation::Timestamp::from_unix_millis(120), + )? + .crypto_state_id(); + let from_suite = implementation::CryptoSuiteDescriptor::v1()?; + let to_suite = in_place_suite(2)?; + let (begin_migration, migration_id) = begin_migration_event( + &production, + &from_suite, + secret(&controller_secrets, 9)?, + to_suite.clone(), + secret(&controller_secrets, 19)?, + 121, + )?; + let begin_migration_id = begin_migration.event_id()?; + apply_production(&mut production, &begin_migration, "begin_migration")?; + let reference_id = reference.apply(IdentityOperation::BeginMigration, &[9])?; + lineage.record(&begin_migration, reference_id)?; + compare_step( + "migration_begin", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let controller_nine_id = production_controller_id(&controller_labels, 9)?; + let activate = authorized_event_with_crypto_keys( + &production, + implementation::AccountOperation::ActivateCryptoMigration( + implementation::ActivateCryptoMigration::try_new( + implementation::ProtocolVersion::V1, + migration_id, + begin_migration_id, + implementation::Extensions::default(), + )?, + ), + production.epoch().checked_next()?, + 122, + controller_nine_id, + &[(&from_suite, secret(&controller_secrets, 9)?, false)], + )?; + let activation_id = activate.event_id()?; + apply_production(&mut production, &activate, "activate_migration")?; + let reference_id = reference.apply(IdentityOperation::ActivateMigration, &[9])?; + lineage.record(&activate, reference_id)?; + compare_step( + "migration_activate", + &production, + &reference.state, + &controller_labels, + &device_labels, + None, + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + let retire = authorized_event_with_crypto_keys( + &production, + implementation::AccountOperation::RetireCryptoSuite( + implementation::RetireCryptoSuite::try_new( + implementation::ProtocolVersion::V1, + migration_id, + implementation::RetireCryptoSuiteMode::RetirePrevious, + activation_id, + None, + implementation::Extensions::default(), + )?, + ), + production.epoch().checked_next()?, + 123, + controller_nine_id, + &[ + (&from_suite, secret(&controller_secrets, 9)?, false), + (&to_suite, secret(&controller_secrets, 19)?, true), + ], + )?; + apply_production(&mut production, &retire, "retire_previous_suite")?; + let reference_id = reference.apply(IdentityOperation::CompleteMigration, &[9])?; + lineage.record(&retire, reference_id)?; + let post_migration_crypto_state = implementation::build_checkpoint_body( + &production, + implementation::Timestamp::from_unix_millis(123), + )? + .crypto_state_id(); + let probe_controller = production_controller(&SecretKey::from_bytes(&[20; 32]), 1)?; + let new_suite_probe = authorized_event_with_crypto_keys( + &production, + implementation::AccountOperation::AddController(probe_controller.clone()), + production.epoch().checked_next()?, + 124, + controller_nine_id, + &[(&to_suite, secret(&controller_secrets, 19)?, true)], + )?; + let new_suite_authorized = production + .clone() + .validate_and_apply(&new_suite_probe) + .is_ok(); + let old_suite_probe = authorized_event_with_crypto_keys( + &production, + implementation::AccountOperation::AddController(probe_controller), + production.epoch().checked_next()?, + 125, + controller_nine_id, + &[(&from_suite, secret(&controller_secrets, 9)?, false)], + )?; + let old_suite_rejected = production + .clone() + .validate_and_apply(&old_suite_probe) + .is_err(); + let migration_evidence = CompletedMigrationEvidence { + before_crypto_state: pre_migration_crypto_state, + after_crypto_state: post_migration_crypto_state, + new_suite_authorized, + old_suite_rejected, + }; + compare_step( + "migration_complete", + &production, + &reference.state, + &controller_labels, + &device_labels, + Some(&migration_evidence), + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + reference.state.rotate_group_key()?; + let expected_recipient_ids = reference + .state + .snapshot() + .group_key_recipients + .iter() + .map(|device| production_device_id(&device_labels, device.get())) + .collect::, _>>()?; + let application_id = implementation::ApplicationId::new(implementation::Digest::new( + implementation::HashAlgorithm::Blake3_256, + [0xa1; 32], + )); + let group_id = implementation::GroupId::new(implementation::Digest::new( + implementation::HashAlgorithm::Blake3_256, + [0xb2; 32], + )); + let distribution = implementation::GroupKeyDistributionSnapshot::from_post_state( + &production, + application_id, + group_id, + implementation::GroupKeyEpoch::new(1), + expected_recipient_ids, + )?; + let revoked_recipient_rejected = matches!( + implementation::GroupKeyDistributionSnapshot::from_post_state( + &production, + application_id, + group_id, + implementation::GroupKeyEpoch::new(1), + vec![production_device_id(&device_labels, 7)?], + ), + Err(implementation::IdentityError::DeviceRevoked) + ); + let mut rng = RepeatingRng(seed.as_bytes()[3]); + let rotation = implementation::rotate_group_key_with_rng( + &distribution, + &implementation::GroupKey::new([0x90; 32]), + &mut rng, + )?; + production_group_recipients = rotation + .expected_recipient_ids() + .map(|device| { + device_labels + .get(&device) + .copied() + .ok_or(DifferentialError::UnknownProductionIdentity) + }) + .collect::, _>>()?; + production_group_key_generation = production_group_key_generation + .checked_add(1) + .ok_or(DifferentialError::Bounds)?; + let group_rotation_wraps = u64::try_from(rotation.recipient_key_wraps().as_slice().len())?; + compare_step( + "group_recipient_rotation", + &production, + &reference.state, + &controller_labels, + &device_labels, + Some(&migration_evidence), + &production_group_recipients, + production_group_key_generation, + &lineage, + &mut steps, + )?; + + Ok(DifferentialHistoryReport { + root_seed: *seed.as_bytes(), + selected_fork_branch: selected_name.to_owned(), + coverage: DifferentialCoverage { + policy_change: true, + controller_revocation: true, + device_revocation: true, + fork_and_resolution: true, + recovery: true, + migration: true, + group_recipient_rotation: true, + }, + production_evidence: DifferentialProductionEvidence { + migration_crypto_commitment_changed: migration_evidence.before_crypto_state + != migration_evidence.after_crypto_state, + new_suite_authorized, + old_suite_rejected, + revoked_recipient_rejected, + group_rotation_wraps, + }, + steps, + }) +} + +#[derive(Clone, Copy, Debug)] +struct CompletedMigrationEvidence { + before_crypto_state: implementation::CryptoStateId, + after_crypto_state: implementation::CryptoStateId, + new_suite_authorized: bool, + old_suite_rejected: bool, +} + +#[derive(Debug, Default)] +struct DifferentialLineage { + events: BTreeMap)>, +} + +impl DifferentialLineage { + fn record( + &mut self, + production: &implementation::AuthorizedEvent, + reference: EventId, + ) -> Result<(), DifferentialError> { + let production_id = production.event_id()?; + if self.events.contains_key(&production_id) + || self + .events + .values() + .any(|(label, _)| *label == reference.get()) + { + return Err(DifferentialError::UnexpectedProductionState); + } + let mut predecessors = match production.body().predecessors().event_heads() { + Some(heads) => heads + .iter() + .map(|head| { + self.events + .get(head) + .map(|(label, _)| *label) + .ok_or(DifferentialError::UnexpectedProductionState) + }) + .collect::, _>>()?, + None if production.body().sequence().get() == 1 => vec![0], + None => return Err(DifferentialError::UnexpectedProductionState), + }; + predecessors.sort_unstable(); + self.events + .insert(production_id, (reference.get(), predecessors)); + Ok(()) + } + + fn canonical_heads( + &self, + production: &implementation::AccountState, + ) -> Result>, DifferentialError> { + production + .heads() + .iter() + .map(|head| { + self.events + .get(head) + .cloned() + .ok_or(DifferentialError::UnexpectedProductionState) + }) + .collect() + } +} + +#[allow(clippy::too_many_arguments)] +fn compare_step( + action: &str, + production: &implementation::AccountState, + reference: &AccountControlModel, + controller_labels: &BTreeMap, + device_labels: &BTreeMap, + completed_migration: Option<&CompletedMigrationEvidence>, + production_group_recipients: &BTreeSet, + production_group_key_generation: u64, + lineage: &DifferentialLineage, + steps: &mut Vec, +) -> Result<(), DifferentialError> { + let implementation = production_snapshot( + production, + controller_labels, + device_labels, + completed_migration, + production_group_recipients, + production_group_key_generation, + lineage, + )?; + let reference = reference_snapshot(reference); + steps.push(implementation.compare(action, reference)?); + Ok(()) +} + +fn production_snapshot( + state: &implementation::AccountState, + controller_labels: &BTreeMap, + device_labels: &BTreeMap, + completed_migration: Option<&CompletedMigrationEvidence>, + group_key_recipients: &BTreeSet, + group_key_generation: u64, + lineage: &DifferentialLineage, +) -> Result { + let mut active_controllers = state + .active_controllers() + .iter() + .map(|controller| label_controller(controller_labels, controller.id())) + .collect::, _>>()?; + let mut revoked_controllers = state + .revoked_controllers() + .iter() + .map(|controller| label_controller(controller_labels, controller.id())) + .collect::, _>>()?; + let mut active_devices = Vec::new(); + let mut revoked_devices = Vec::new(); + for device in state.devices() { + let label = *device_labels + .get(&device.id()) + .ok_or(DifferentialError::UnknownProductionIdentity)?; + match device.lifecycle() { + implementation::ProjectedDeviceLifecycle::Active => active_devices.push(label), + implementation::ProjectedDeviceLifecycle::Revoked => revoked_devices.push(label), + implementation::ProjectedDeviceLifecycle::Suspended => { + return Err(DifferentialError::UnexpectedProductionState); + } + } + } + active_controllers.sort_unstable(); + revoked_controllers.sort_unstable(); + active_devices.sort_unstable(); + revoked_devices.sort_unstable(); + let required = state + .control_policy() + .rule_for(implementation::OperationKind::AddController) + .ok_or(DifferentialError::UnexpectedProductionState)? + .required_weight() + .get(); + let migration = match state.lifecycle() { + implementation::ProjectionLifecycle::MigrationPending => MigrationState::Pending, + implementation::ProjectionLifecycle::MigrationDual => MigrationState::Dual, + implementation::ProjectionLifecycle::Active => match completed_migration { + Some(evidence) + if evidence.before_crypto_state != evidence.after_crypto_state + && evidence.new_suite_authorized + && evidence.old_suite_rejected + && implementation::build_checkpoint_body( + state, + implementation::Timestamp::from_unix_millis(200), + )? + .crypto_state_id() + == evidence.after_crypto_state => + { + MigrationState::Complete + } + Some(_) => return Err(DifferentialError::InvalidMigrationEvidence), + None => MigrationState::Stable, + }, + implementation::ProjectionLifecycle::Forked => MigrationState::Stable, + _ => return Err(DifferentialError::UnexpectedProductionState), + }; + Ok(DifferentialSnapshot { + account_id: *state.account_id().as_digest().as_bytes(), + sequence: state.sequence().get(), + epoch: state.epoch().get(), + canonical_heads: lineage.canonical_heads(state)?, + active_controllers, + revoked_controllers, + active_devices, + revoked_devices, + required_weight: u16::try_from(required)?, + forked: state.lifecycle() == implementation::ProjectionLifecycle::Forked, + migration, + group_key_generation, + group_key_recipients: group_key_recipients.iter().copied().collect(), + }) +} + +fn reference_snapshot(state: &AccountControlModel) -> DifferentialSnapshot { + let snapshot = state.snapshot(); + let mut active_devices = Vec::new(); + let mut revoked_devices = Vec::new(); + for (device, lifecycle) in snapshot.devices { + match lifecycle { + DeviceLifecycle::Active => active_devices.push(device.get()), + DeviceLifecycle::Revoked => revoked_devices.push(device.get()), + } + } + DifferentialSnapshot { + account_id: snapshot.account_id, + sequence: snapshot.sequence, + epoch: snapshot.epoch, + canonical_heads: state.canonical_head_predecessors(), + active_controllers: snapshot + .active_controllers + .into_iter() + .map(|controller| controller.id().get()) + .collect(), + revoked_controllers: snapshot + .revoked_controllers + .into_iter() + .map(ControllerId::get) + .collect(), + active_devices, + revoked_devices, + required_weight: snapshot.policy.required_weight(), + forked: snapshot.forked, + migration: snapshot.migration, + group_key_generation: snapshot.group_key_generation, + group_key_recipients: snapshot + .group_key_recipients + .into_iter() + .map(DeviceId::get) + .collect(), + } +} + +#[derive(Debug)] +struct ReferenceHarness { + state: AccountControlModel, + next_event: u64, + fork_heads: Vec, + fork_base: Option<(EventId, u64, u64)>, +} + +impl ReferenceHarness { + fn new(account_id: [u8; 32]) -> Result { + Ok(Self { + state: AccountControlModel::new( + account_id, + vec![ + ModelController::new(ControllerId::new(1), 1)?, + ModelController::new(ControllerId::new(2), 1)?, + ], + ModelPolicy::new(1)?, + )?, + next_event: 1, + fork_heads: Vec::new(), + fork_base: None, + }) + } + + fn position(&self) -> Result<(EventId, u64, u64), DifferentialError> { + let snapshot = self.state.snapshot(); + let predecessor = match snapshot.heads.as_slice() { + [] if snapshot.sequence == 0 => EventId::new(0), + [head] if !snapshot.forked => *head, + _ => return Err(DifferentialError::UnexpectedReferenceState), + }; + Ok((predecessor, snapshot.sequence, snapshot.epoch)) + } + + fn next_id(&mut self) -> Result { + let id = EventId::new(self.next_event); + self.next_event = self + .next_event + .checked_add(1) + .ok_or(DifferentialError::Bounds)?; + Ok(id) + } + + fn apply( + &mut self, + operation: IdentityOperation, + approvals: &[u16], + ) -> Result { + let (predecessor, sequence, epoch) = self.position()?; + let resulting_epoch = operation.resulting_epoch(epoch)?; + let event = IdentityEvent::new( + self.next_id()?, + predecessor, + sequence.checked_add(1).ok_or(DifferentialError::Bounds)?, + resulting_epoch, + approvals.iter().copied().map(ControllerId::new).collect(), + operation, + )?; + let id = event.id(); + self.state.apply(&event)?; + Ok(id) + } + + fn fork_event( + &mut self, + base: (EventId, u64, u64), + operation: IdentityOperation, + approvals: &[u16], + ) -> Result { + self.fork_base = Some(base); + let event = IdentityEvent::new( + self.next_id()?, + base.0, + base.1.checked_add(1).ok_or(DifferentialError::Bounds)?, + base.2.checked_add(1).ok_or(DifferentialError::Bounds)?, + approvals.iter().copied().map(ControllerId::new).collect(), + operation, + )?; + let id = event.id(); + self.state.apply(&event)?; + self.fork_heads.push(id); + Ok(id) + } + + fn resolve_fork( + &mut self, + selected: EventId, + approvals: &[u16], + ) -> Result { + let base = self + .fork_base + .ok_or(DifferentialError::UnexpectedReferenceState)?; + let resolution = ForkResolution::new( + self.next_id()?, + self.fork_heads.clone(), + selected, + base.1.checked_add(2).ok_or(DifferentialError::Bounds)?, + base.2.checked_add(2).ok_or(DifferentialError::Bounds)?, + approvals.iter().copied().map(ControllerId::new).collect(), + Vec::new(), + Vec::new(), + )?; + let id = resolution.id(); + self.state.resolve_fork(&resolution)?; + Ok(id) + } + + fn recover( + &mut self, + controllers: Vec<(u16, u16)>, + required_weight: u16, + begin_approvals: &[u16], + ) -> Result<(EventId, EventId), DifferentialError> { + let plan = RecoveryPlan::new( + controllers + .into_iter() + .map(|(id, weight)| ModelController::new(ControllerId::new(id), weight)) + .collect::, _>>()?, + ModelPolicy::new(required_weight)?, + )?; + let begin = self.apply(IdentityOperation::BeginRecovery, begin_approvals)?; + let (predecessor, sequence, epoch) = self.position()?; + let event = IdentityEvent::new( + self.next_id()?, + predecessor, + sequence.checked_add(1).ok_or(DifferentialError::Bounds)?, + epoch.checked_add(1).ok_or(DifferentialError::Bounds)?, + Vec::new(), + IdentityOperation::Recover(plan), + )?; + let finalize = event.id(); + self.state.apply_recovery(&event)?; + Ok((begin, finalize)) + } +} + +fn control_policy( + required_weight: u32, + extension_fill: u8, +) -> Result { + let operations = [ + implementation::OperationKind::AuthorizeDevice, + implementation::OperationKind::RevokeDevice, + implementation::OperationKind::AddController, + implementation::OperationKind::RemoveController, + implementation::OperationKind::ChangeControlPolicy, + implementation::OperationKind::BeginRecovery, + implementation::OperationKind::FinalizeRecovery, + implementation::OperationKind::ResolveFork, + implementation::OperationKind::BeginCryptoMigration, + implementation::OperationKind::ActivateCryptoMigration, + implementation::OperationKind::RetireCryptoSuite, + ]; + let rules = operations + .into_iter() + .map(|operation| policy_rule(operation, required_weight)) + .collect::, _>>()?; + let extensions = implementation::Extensions::new(vec![implementation::Extension::new( + 100, + false, + vec![extension_fill], + )?])?; + Ok(implementation::ControlPolicy::new(rules, extensions)?) +} + +fn policy_rule( + operation: implementation::OperationKind, + required_weight: u32, +) -> Result { + let freshness = if operation == implementation::OperationKind::FinalizeRecovery { + implementation::FreshnessRequirement::provider_quorum( + implementation::ProviderFreshness::new( + implementation::ProviderQuorum::new(1)?, + implementation::DurationMillis::new(1_000), + )?, + ) + } else { + implementation::FreshnessRequirement::latest_known() + }; + Ok(implementation::PolicyRule::new( + operation, + implementation::RequiredWeight::new(required_weight)?, + implementation::ControllerSelector::any_active(), + freshness, + None, + implementation::Extensions::default(), + )?) +} + +fn recovery_policy() -> Result { + Ok(implementation::RecoveryPolicy::new( + implementation::RecoveryPolicyVersion::GENESIS, + implementation::RecoveryAuthority::controller_threshold( + implementation::ControllerThreshold::new( + implementation::ControllerSelector::any_active(), + implementation::RequiredWeight::new(1)?, + ), + ), + implementation::DurationMillis::new(10), + implementation::DurationMillis::new(100), + implementation::Extensions::default(), + )?) +} + +fn provider_policy( + provider_secret: &SecretKey, +) -> Result { + let provider = implementation::ProviderDescriptor::new( + implementation::SigningPublicKey::ed25519(*provider_secret.public().as_bytes())?, + implementation::Extensions::default(), + )?; + Ok(implementation::ProviderPolicy::replicated( + implementation::ProviderPolicyVersion::GENESIS, + vec![provider], + implementation::ProviderQuorum::new(1)?, + implementation::ProviderQuorum::new(1)?, + implementation::DurationMillis::new(1_000), + implementation::Extensions::default(), + )?) +} + +fn production_controller( + secret: &SecretKey, + weight: u32, +) -> Result { + Ok(implementation::ControllerDescriptor::new( + implementation::SigningPublicKey::ed25519(*secret.public().as_bytes())?, + implementation::ControllerClass::PersonalDevice, + implementation::ControllerWeight::new(weight)?, + implementation::ControllerScope::all_v1_operations(), + implementation::Extensions::default(), + )?) +} + +fn production_device( + application_secret: &SecretKey, + agreement_label: u16, + endpoint_secret: &SecretKey, +) -> Result { + let mut agreement = [0_u8; 32]; + agreement[0] = u8::try_from(agreement_label)?; + Ok(implementation::DeviceDescriptor::new( + implementation::SigningPublicKey::ed25519(*application_secret.public().as_bytes())?, + implementation::AgreementPublicKey::x25519(agreement)?, + implementation::EndpointPublicKey::new(implementation::SigningPublicKey::ed25519( + *endpoint_secret.public().as_bytes(), + )?), + implementation::Extensions::default(), + )?) +} + +fn authorized_event( + state: &implementation::AccountState, + operation: implementation::AccountOperation, + resulting_epoch: implementation::Epoch, + nonce: u8, + signer: &SecretKey, +) -> Result { + let predecessors = if state.sequence() == implementation::Sequence::GENESIS { + implementation::EventPredecessors::genesis(state.genesis_anchor()) + } else { + implementation::EventPredecessors::events(state.heads().to_vec())? + }; + let body = implementation::EventBody::new( + state.account_id(), + state.sequence().checked_next()?, + resulting_epoch, + predecessors, + operation, + implementation::Timestamp::from_unix_millis(u64::from(nonce)), + [nonce; 16], + implementation::Extensions::default(), + )?; + authorize_body(state, body, signer) +} + +fn authorize_body( + state: &implementation::AccountState, + body: implementation::EventBody, + signer: &SecretKey, +) -> Result { + let checkpoint_id = typed_id::(0x44)?; + let delay = if matches!( + body.operation(), + implementation::AccountOperation::BeginRecovery(_) + ) { + let proposal_id = body.proposal_id()?; + implementation::DelayEvidence::provider_quorum( + state.provider_policy_id(), + implementation::ProviderQuorum::new(1)?, + controller_intent_approvals(state, &body, signer)?, + implementation::ProviderReceipts::new(vec![provider_receipt( + state, + implementation::ProviderLogSubject::EventIntent(proposal_id), + 100, + 100, + 0x67, + &SecretKey::from_bytes(&[99; 32]), + )?])?, + )? + } else { + implementation::DelayEvidence::none() + }; + let evidence = implementation::AdmissionEvidence::new( + body.proposal_id()?, + checkpoint_id, + state.provider_policy_id(), + implementation::FreshnessEvidence::local_known(checkpoint_id), + delay, + implementation::Extensions::default(), + )?; + authorize_body_with_evidence(state, body, evidence, signer) +} + +fn authorize_body_with_evidence( + state: &implementation::AccountState, + body: implementation::EventBody, + evidence: implementation::AdmissionEvidence, + signer: &SecretKey, +) -> Result { + use implementation::CanonicalWire; + let event_id = evidence.event_id_for_body(&body)?; + let signer_key = implementation::SigningPublicKey::ed25519(*signer.public().as_bytes())?; + let controller = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signer_key) + .ok_or(DifferentialError::UnknownProductionIdentity)?; + let approval_body = implementation::ControllerApprovalBody::event( + controller.id(), + event_id, + evidence.admission_evidence_id()?, + implementation::Extensions::default(), + )?; + let signature = signer.sign(&approval_body.to_canonical_bytes()?); + let keyed = implementation::KeyedSignature::new( + implementation::CryptoSuiteDescriptor::v1()?.crypto_suite_id()?, + implementation::ControllerKeyId::for_signing_key(&signer_key)?, + implementation::AlgorithmSignature::new(1, signature.to_bytes().to_vec())?, + ); + let approval = implementation::SignedControllerApproval::new(approval_body, vec![keyed])?; + Ok(implementation::AuthorizedEvent::new( + body, + evidence, + implementation::ControllerApprovals::new(vec![approval])?, + )?) +} + +fn controller_intent_approvals( + state: &implementation::AccountState, + body: &implementation::EventBody, + signer: &SecretKey, +) -> Result { + use implementation::CanonicalWire; + let signer_key = implementation::SigningPublicKey::ed25519(*signer.public().as_bytes())?; + let controller = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signer_key) + .ok_or(DifferentialError::UnknownProductionIdentity)?; + let approval_body = implementation::EventIntentApprovalBody::new( + controller.id(), + body.proposal_id()?, + implementation::Extensions::default(), + )?; + let signature = signer.sign(&approval_body.to_canonical_bytes()?); + Ok(implementation::EventIntentApprovals::new(vec![ + implementation::SignedEventIntentApproval::new( + approval_body, + vec![implementation::KeyedSignature::new( + implementation::CryptoSuiteDescriptor::v1()?.crypto_suite_id()?, + implementation::ControllerKeyId::for_signing_key(&signer_key)?, + implementation::AlgorithmSignature::new(1, signature.to_bytes().to_vec())?, + )], + )?, + ])?) +} + +fn begin_recovery_operation( + state: &implementation::AccountState, + controllers: Vec, +) -> Result<(implementation::AccountOperation, implementation::RecoveryId), DifferentialError> { + let plan = implementation::RecoveryAuthorityPlan::try_new( + implementation::ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44)?, + *state.heads().first().ok_or(DifferentialError::Bounds)?, + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0x51; 32], + controllers, + state.control_policy().clone(), + state.recovery_policy().clone(), + Vec::new(), + implementation::Timestamp::from_unix_millis(1_000), + implementation::Extensions::default(), + )?; + let proposal = implementation::RecoveryProposal::try_new( + implementation::ProtocolVersion::V1, + plan, + implementation::Extensions::default(), + )?; + let recovery_id = proposal.recovery_id()?; + let evidence = implementation::RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ); + Ok(( + implementation::AccountOperation::BeginRecovery(implementation::BeginRecovery::try_new( + implementation::ProtocolVersion::V1, + proposal, + evidence, + implementation::Extensions::default(), + )?), + recovery_id, + )) +} + +fn finalize_recovery_event( + state: &implementation::AccountState, + recovery_id: implementation::RecoveryId, + begin_proposal_id: implementation::ProposalId, + nonce: u8, + provider_secret: &SecretKey, +) -> Result { + let anchor = implementation::RecoveryDelayAnchor::try_new( + implementation::ProtocolVersion::V1, + state.account_id(), + recovery_id, + begin_proposal_id, + state.provider_policy_id(), + implementation::ProviderQuorum::new(1)?, + implementation::ProviderReceipts::new(vec![provider_receipt( + state, + implementation::ProviderLogSubject::EventIntent(begin_proposal_id), + 100, + 110, + 0x67, + provider_secret, + )?])?, + implementation::Extensions::default(), + )?; + let operation = implementation::AccountOperation::FinalizeRecovery( + implementation::FinalizeRecovery::try_new( + implementation::ProtocolVersion::V1, + recovery_id, + anchor, + implementation::Timestamp::from_unix_millis(110), + implementation::Extensions::default(), + )?, + ); + let body = implementation::EventBody::new( + state.account_id(), + state.sequence().checked_next()?, + state.epoch().checked_next()?, + implementation::EventPredecessors::events(state.heads().to_vec())?, + operation, + implementation::Timestamp::from_unix_millis(110), + [nonce; 16], + implementation::Extensions::default(), + )?; + let checkpoint_id = typed_id::(0x44)?; + let completion = implementation::ProviderReceipts::new(vec![provider_receipt( + state, + implementation::ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + 110, + nonce, + provider_secret, + )?])?; + let evidence = implementation::AdmissionEvidence::new( + body.proposal_id()?, + checkpoint_id, + state.provider_policy_id(), + implementation::FreshnessEvidence::provider_quorum( + checkpoint_id, + state.provider_policy_id(), + completion, + )?, + implementation::DelayEvidence::none(), + implementation::Extensions::default(), + )?; + Ok(implementation::AuthorizedEvent::new( + body, + evidence, + implementation::ControllerApprovals::new(Vec::new())?, + )?) +} + +fn provider_receipt( + state: &implementation::AccountState, + subject: implementation::ProviderLogSubject, + entry_observed_at: u64, + head_observed_at: u64, + log_fill: u8, + provider_secret: &SecretKey, +) -> Result { + let provider = implementation::ProviderDescriptor::new( + implementation::SigningPublicKey::ed25519(*provider_secret.public().as_bytes())?, + implementation::Extensions::default(), + )?; + let log_id = typed_id::(log_fill)?; + let entry = implementation::ProviderLogEntryBody::new( + provider.id()?, + log_id, + state.account_id(), + subject, + implementation::Timestamp::from_unix_millis(entry_observed_at), + implementation::Extensions::default(), + )?; + let head = implementation::ProviderHeadBody::new( + provider.id()?, + log_id, + implementation::ProviderKeyVersion::GENESIS, + 1, + entry.merkle_leaf_hash()?, + implementation::Timestamp::from_unix_millis(head_observed_at), + implementation::Extensions::default(), + )?; + let signature = provider_secret.sign(&head.signing_bytes()?); + Ok(implementation::InclusionReceipt::new( + entry, + 0, + Vec::new(), + implementation::SignedProviderHead::new( + head, + implementation::ProtocolSignature::ed25519(signature.to_bytes()), + ), + )?) +} + +fn in_place_suite(code: u16) -> Result { + let v1 = implementation::CryptoSuiteDescriptor::v1()?; + Ok(implementation::CryptoSuiteDescriptor::try_new( + implementation::ProtocolVersion::V1, + code, + v1.hash_algorithm_code(), + v1.signature_algorithm_code(), + v1.agreement_algorithm_code(), + v1.kdf_algorithm_code(), + v1.aead_algorithm_code(), + implementation::Extensions::default(), + )?) +} + +fn begin_migration_event( + state: &implementation::AccountState, + from_suite: &implementation::CryptoSuiteDescriptor, + old_secret: &SecretKey, + to_suite: implementation::CryptoSuiteDescriptor, + new_secret: &SecretKey, + nonce: u8, +) -> Result< + ( + implementation::AuthorizedEvent, + implementation::CryptoMigrationId, + ), + DifferentialError, +> { + use implementation::CanonicalWire; + let controller_id = state + .active_controllers() + .first() + .ok_or(DifferentialError::Bounds)? + .id(); + let old_signing_key = + implementation::SigningPublicKey::ed25519(*old_secret.public().as_bytes())?; + let old_key_id = implementation::ControllerKeyId::for_signing_key(&old_signing_key)?; + let migration = implementation::CryptoMigrationBody::try_new( + implementation::ProtocolVersion::V1, + state.account_id(), + from_suite.crypto_suite_id()?, + to_suite.clone(), + vec![implementation::ControllerKeyBinding::try_new( + controller_id, + old_key_id, + implementation::AlgorithmPublicKey::new( + to_suite.signature_algorithm_code(), + new_secret.public().as_bytes().to_vec(), + )?, + implementation::Extensions::default(), + )?], + None, + [nonce; 32], + implementation::Extensions::default(), + )?; + let migration_id = migration.crypto_migration_id()?; + let migration_bytes = migration_id.to_canonical_bytes()?; + let proof = implementation::ControllerKeyBindingProof::try_new( + migration_id, + controller_id, + implementation::AlgorithmSignature::new( + 1, + old_secret.sign(&migration_bytes).to_bytes().to_vec(), + )?, + implementation::AlgorithmSignature::new( + 1, + new_secret.sign(&migration_bytes).to_bytes().to_vec(), + )?, + )?; + let operation = implementation::AccountOperation::BeginCryptoMigration( + implementation::BeginCryptoMigration::try_new( + implementation::ProtocolVersion::V1, + migration, + implementation::ControllerKeyBindingProofSet::try_new(vec![proof])?, + implementation::Extensions::default(), + )?, + ); + Ok(( + authorized_event_with_crypto_keys( + state, + operation, + state.epoch(), + nonce, + controller_id, + &[(from_suite, old_secret, false)], + )?, + migration_id, + )) +} + +fn authorized_event_with_crypto_keys( + state: &implementation::AccountState, + operation: implementation::AccountOperation, + resulting_epoch: implementation::Epoch, + nonce: u8, + controller_id: implementation::ControllerId, + signers: &[(&implementation::CryptoSuiteDescriptor, &SecretKey, bool)], +) -> Result { + let body = implementation::EventBody::new( + state.account_id(), + state.sequence().checked_next()?, + resulting_epoch, + implementation::EventPredecessors::events(state.heads().to_vec())?, + operation, + implementation::Timestamp::from_unix_millis(u64::from(nonce)), + [nonce; 16], + implementation::Extensions::default(), + )?; + authorize_body_with_crypto_keys(state, body, controller_id, signers) +} + +fn authorize_body_with_crypto_keys( + state: &implementation::AccountState, + body: implementation::EventBody, + controller_id: implementation::ControllerId, + signers: &[(&implementation::CryptoSuiteDescriptor, &SecretKey, bool)], +) -> Result { + use implementation::CanonicalWire; + let checkpoint_id = typed_id::(0x44)?; + let evidence = implementation::AdmissionEvidence::new( + body.proposal_id()?, + checkpoint_id, + state.provider_policy_id(), + implementation::FreshnessEvidence::local_known(checkpoint_id), + implementation::DelayEvidence::none(), + implementation::Extensions::default(), + )?; + let event_id = evidence.event_id_for_body(&body)?; + let approval_body = implementation::ControllerApprovalBody::event( + controller_id, + event_id, + evidence.admission_evidence_id()?, + implementation::Extensions::default(), + )?; + let bytes = approval_body.to_canonical_bytes()?; + let keyed = signers + .iter() + .map(|(suite, signer, migrated)| { + let signing_key = + implementation::SigningPublicKey::ed25519(*signer.public().as_bytes())?; + let key_id = if *migrated { + implementation::ControllerKeyId::for_algorithm_key( + &implementation::AlgorithmPublicKey::new( + suite.signature_algorithm_code(), + signing_key.as_bytes().to_vec(), + )?, + )? + } else { + implementation::ControllerKeyId::for_signing_key(&signing_key)? + }; + Ok(implementation::KeyedSignature::new( + suite.crypto_suite_id()?, + key_id, + implementation::AlgorithmSignature::new( + suite.signature_algorithm_code(), + signer.sign(&bytes).to_bytes().to_vec(), + )?, + )) + }) + .collect::, DifferentialError>>()?; + let approval = implementation::SignedControllerApproval::new(approval_body, keyed)?; + Ok(implementation::AuthorizedEvent::new( + body, + evidence, + implementation::ControllerApprovals::new(vec![approval])?, + )?) +} + +fn typed_id(fill: u8) -> Result { + use implementation::CanonicalWire; + let digest = implementation::Digest::new(implementation::HashAlgorithm::Blake3_256, [fill; 32]); + Ok(T::from_canonical_bytes(&digest.to_canonical_bytes()?)?) +} + +fn secret(secrets: &BTreeMap, label: u16) -> Result<&SecretKey, DifferentialError> { + secrets + .get(&label) + .ok_or(DifferentialError::UnknownProductionIdentity) +} + +fn label_controller( + labels: &BTreeMap, + id: implementation::ControllerId, +) -> Result { + labels + .get(&id) + .copied() + .ok_or(DifferentialError::UnknownProductionIdentity) +} + +fn production_controller_id( + labels: &BTreeMap, + label: u16, +) -> Result { + labels + .iter() + .find_map(|(id, candidate)| (*candidate == label).then_some(*id)) + .ok_or(DifferentialError::UnknownProductionIdentity) +} + +fn production_device_id( + labels: &BTreeMap, + label: u16, +) -> Result { + labels + .iter() + .find_map(|(id, candidate)| (*candidate == label).then_some(*id)) + .ok_or(DifferentialError::UnknownProductionIdentity) +} + +#[derive(Debug)] +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from_le_bytes([self.0; 4])) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from_le_bytes([self.0; 8])) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + self.0 = self.0.wrapping_add(1); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +fn apply_production( + state: &mut implementation::AccountState, + event: &implementation::AuthorizedEvent, + action: &'static str, +) -> Result<(), DifferentialError> { + state + .validate_and_apply(event) + .map(|_| ()) + .map_err(|source| DifferentialError::ProductionStep { action, source }) +} + +/// Production construction, independent-model, or semantic comparison failure. +#[derive(Debug, thiserror::Error)] +pub enum DifferentialError { + #[error("production identity transition failed: {0}")] + Production(#[from] implementation::IdentityError), + #[error("production identity transition {action} failed: {source}")] + ProductionStep { + action: &'static str, + source: implementation::IdentityError, + }, + #[error("independent reference transition failed: {0}")] + Reference(#[from] super::ModelError), + #[error("generated identity history exceeded a numeric or collection bound")] + Bounds, + #[error("generated identity label is unknown to the production adapter")] + UnknownProductionIdentity, + #[error("production projection entered an unexpected state")] + UnexpectedProductionState, + #[error("production migration evidence did not prove the post-retirement suite")] + InvalidMigrationEvidence, + #[error("reference projection entered an unexpected state")] + UnexpectedReferenceState, + #[error("numeric conversion failed: {0}")] + Conversion(#[from] std::num::TryFromIntError), + #[error("implementation/reference divergence at {action}")] + Divergence { + action: String, + implementation: Box, + reference: Box, + }, +} diff --git a/krikos-sim/src/identity/corpus.rs b/krikos-sim/src/identity/corpus.rs new file mode 100644 index 00000000000..70ca6b784cf --- /dev/null +++ b/krikos-sim/src/identity/corpus.rs @@ -0,0 +1,880 @@ +//! Strict recorded identity corpus and signature-preserving failure minimization. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path, PathBuf}, +}; + +use krikos_runtime::{RootSeed, TraceEvent}; +use serde::{Deserialize, Serialize}; + +use super::{ + IdentityCoverage, IdentityFailedRunRecord, IdentityRunOutcome, IdentityRunReport, + IdentityScenario, IdentityScenarioError, IdentityScenarioRunner, +}; +use crate::{ArtifactStore, RunManifest, bounded_io::read_file, normalized_trace_json}; + +/// Strict identity corpus manifest schema. +pub const IDENTITY_CORPUS_SCHEMA_VERSION: u16 = 2; +const MAX_IDENTITY_CORPUS_ENTRIES: usize = 32; +pub(crate) const MAX_IDENTITY_MINIMIZATION_ATTEMPTS: u64 = 1_024; +const IDENTITY_FAILURE_ARTIFACT_SCHEMA_VERSION: u16 = 2; + +/// Expected terminal state of a permanent reviewed identity regression. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "terminal", rename_all = "snake_case", deny_unknown_fields)] +pub enum IdentityCorpusExpectation { + /// The scenario must complete without a model or invariant failure. + Success, + /// The pre-fix regression seed must reproduce one exact failure identity. + ExpectedFailure { signature: IdentityFailureSignature }, +} + +/// Source-bound evidence required before a failure candidate may be marked reviewed. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityCorpusPromotionEvidence { + /// Digest of the committed failure-artifact index used during review. + pub artifact_index_digest: String, + /// Exact source revision on which the failure was replay-confirmed. + pub source_revision: String, + /// Human-review issue or audit reference. + pub issue: String, + /// The same-seed minimized terminal was replay-confirmed. + pub replay_confirmed: bool, + /// Every accepted reduction retained the exact signature. + pub signature_preserving: bool, +} + +/// One reviewed, recorded-seed identity regression entry. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityCorpusEntry { + /// Stable ID equal to the scenario ID. + pub id: String, + /// One immediate JSON file in the corpus root. + pub scenario_file: String, + /// Lowercase 32-byte hexadecimal root seed. + pub seed: String, + /// Whether this entry succeeds now or intentionally preserves a pre-fix terminal. + pub expectation: IdentityCorpusExpectation, + /// Source-bound evidence for expected-failure promotions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub promotion: Option, + /// Human-reviewed permanent entry marker. + pub reviewed: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct IdentityCorpusManifest { + schema_version: u16, + entries: Vec, +} + +/// Loaded scenario plus its reviewed metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoadedIdentityCorpusEntry { + /// Recorded metadata. + pub metadata: IdentityCorpusEntry, + /// Strict parsed scenario. + pub scenario: IdentityScenario, +} + +/// Strict corpus whose aggregate action inventory covers every Lane A requirement. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityCorpus { + entries: Vec, + coverage: IdentityCoverage, +} + +impl IdentityCorpus { + /// Loads only the manifest and its exact declared immediate scenario files. + pub fn load(root: &Path) -> Result { + let files = collect_files(root)?; + let manifest: IdentityCorpusManifest = + serde_json::from_slice(&read_file(root.join("manifest.json"))?) + .map_err(|error| IdentityCorpusError::Encoding(error.to_string()))?; + if manifest.schema_version != IDENTITY_CORPUS_SCHEMA_VERSION + || manifest.entries.is_empty() + || manifest.entries.len() > MAX_IDENTITY_CORPUS_ENTRIES + { + return Err(IdentityCorpusError::InvalidManifest); + } + let mut expected_files = BTreeSet::from(["manifest.json".to_owned()]); + let mut ids = BTreeSet::new(); + let mut seeds = BTreeSet::new(); + let mut entries = Vec::with_capacity(manifest.entries.len()); + let mut coverage = IdentityCoverage::default(); + for metadata in manifest.entries { + if metadata.validate().is_err() + || !ids.insert(metadata.id.clone()) + || !seeds.insert(metadata.seed.clone()) + { + return Err(IdentityCorpusError::InvalidEntry(metadata.id)); + } + decode_seed(&metadata.seed) + .map_err(|_| IdentityCorpusError::InvalidSeed(metadata.id.clone()))?; + if !expected_files.insert(metadata.scenario_file.clone()) { + return Err(IdentityCorpusError::InvalidEntry(metadata.id)); + } + let scenario = + IdentityScenario::from_json(&read_file(root.join(&metadata.scenario_file))?)?; + if scenario.id() != metadata.id { + return Err(IdentityCorpusError::IdMismatch { + metadata: metadata.id, + scenario: scenario.id().to_owned(), + }); + } + coverage.include(IdentityCoverage::from_scenario(&scenario)); + entries.push(LoadedIdentityCorpusEntry { metadata, scenario }); + } + if files != expected_files { + return Err(IdentityCorpusError::UnenumeratedFiles); + } + if !coverage.covers_lane_a() { + return Err(IdentityCorpusError::IncompleteCoverage(coverage)); + } + entries.sort_by(|left, right| left.metadata.id.cmp(&right.metadata.id)); + Ok(Self { entries, coverage }) + } + + /// Stable reviewed entries. + pub fn entries(&self) -> &[LoadedIdentityCorpusEntry] { + &self.entries + } + + /// Aggregate coverage proven by the strict loader. + pub const fn coverage(&self) -> IdentityCoverage { + self.coverage + } + + /// Replays every entry under its independent recorded seed. + pub fn test(&self) -> Result, IdentityCorpusError> { + let mut reports = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + let seed = decode_seed(&entry.metadata.seed) + .map_err(|_| IdentityCorpusError::InvalidSeed(entry.metadata.id.clone()))?; + let outcome = + IdentityScenarioRunner::run_detailed(&entry.scenario, RootSeed::new(seed))?; + let (report, failure) = match outcome { + IdentityRunOutcome::Success(record) => (record.report, None), + IdentityRunOutcome::ExpectedRejection(_) => { + return Err(IdentityCorpusError::UnexpectedExpectedRejection( + entry.metadata.id.clone(), + )); + } + IdentityRunOutcome::Failed(record) => { + let signature = record.signature()?; + (record.report, Some(signature)) + } + }; + let matched = match (&entry.metadata.expectation, &failure) { + (IdentityCorpusExpectation::Success, None) => true, + ( + IdentityCorpusExpectation::ExpectedFailure { + signature: expected, + }, + Some(actual), + ) => expected == actual, + _ => false, + }; + if !matched { + return Err(IdentityCorpusError::TerminalMismatch( + entry.metadata.id.clone(), + )); + } + if !report + .invariants + .all_checked_at_each_step(entry.scenario.actions().len()) + { + return Err(IdentityCorpusError::InvariantAccounting( + entry.metadata.id.clone(), + )); + } + reports.push(IdentityCorpusReport { + id: entry.metadata.id.clone(), + report, + failure, + }); + } + Ok(reports) + } +} + +/// Successful replay evidence for one corpus entry. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityCorpusReport { + /// Stable entry identity. + pub id: String, + /// Exact deterministic run report. + pub report: IdentityRunReport, + /// Exact expected-failure identity, absent for a successful terminal. + pub failure: Option, +} + +impl IdentityCorpusEntry { + fn validate(&self) -> Result<(), IdentityCorpusError> { + if !self.reviewed + || self.id.is_empty() + || !valid_filename(&self.scenario_file) + || decode_seed(&self.seed).is_err() + { + return Err(IdentityCorpusError::InvalidEntry(self.id.clone())); + } + match (&self.expectation, &self.promotion) { + (IdentityCorpusExpectation::Success, None) => Ok(()), + (IdentityCorpusExpectation::ExpectedFailure { signature }, Some(promotion)) + if signature.validate().is_ok() && promotion.is_valid() => + { + Ok(()) + } + _ => Err(IdentityCorpusError::InvalidEntry(self.id.clone())), + } + } +} + +impl IdentityCorpusPromotionEvidence { + fn is_valid(&self) -> bool { + valid_digest(&self.artifact_index_digest) + && valid_revision(&self.source_revision) + && !self.issue.trim().is_empty() + && self.issue.len() <= 512 + && self.replay_confirmed + && self.signature_preserving + } +} + +/// Stable confirmed product-failure identity accepted by the reducer. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityFailureSignature { + /// Bounded normalized failure class. + pub class: String, + /// Lowercase Blake3 evidence digest. + pub evidence_digest: String, +} + +impl IdentityFailureSignature { + /// Creates a signature from bounded evidence without retaining sensitive bytes. + pub fn new(class: impl Into, evidence: &[u8]) -> Result { + let class = class.into(); + let signature = Self { + class, + evidence_digest: blake3::hash(evidence).to_hex().to_string(), + }; + signature.validate()?; + Ok(signature) + } + + /// Parses one strict stable failure identity. + pub fn from_json(bytes: &[u8]) -> Result { + let signature: Self = serde_json::from_slice(bytes) + .map_err(|error| IdentityCorpusError::Encoding(error.to_string()))?; + signature.validate()?; + Ok(signature) + } + + fn validate(&self) -> Result<(), IdentityCorpusError> { + if self.class.is_empty() + || self.class.len() > 128 + || !self.class.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b':' | b'_' | b'-') + }) + || !valid_digest(&self.evidence_digest) + { + return Err(IdentityCorpusError::InvalidSignature); + } + Ok(()) + } + + /// Derives the exact stable signature of a real failed simulator run. + pub fn from_failed_run(failure: &IdentityFailedRunRecord) -> Result { + Self::new( + failure.evidence.class.as_str(), + failure.evidence.detail.as_bytes(), + ) + } +} + +impl IdentityFailedRunRecord { + /// Derives the exact stable signature used for confirmation, reduction, and replay. + pub fn signature(&self) -> Result { + IdentityFailureSignature::from_failed_run(self) + } +} + +/// One deterministic action-deletion attempt. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityMinimizationAttempt { + pub ordinal: u64, + pub removed_action: String, + pub candidate_digest: String, + pub accepted: bool, + pub observed_signature: Option, +} + +/// Best signature-preserving scenario and bounded attempt history. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityMinimizationResult { + pub scenario: IdentityScenario, + pub signature: IdentityFailureSignature, + pub attempts: Vec, + pub exhausted: bool, +} + +/// Bounded deterministic identity scenario reducer. +#[derive(Clone, Copy, Debug)] +pub struct IdentityMinimizer { + max_attempts: u64, +} + +impl IdentityMinimizer { + /// Creates a nonzero, hard-bounded reducer. + pub fn new(max_attempts: u64) -> Result { + if max_attempts == 0 || max_attempts > MAX_IDENTITY_MINIMIZATION_ATTEMPTS { + return Err(IdentityCorpusError::InvalidMinimizationBudget); + } + Ok(Self { max_attempts }) + } + + /// Deletes actions only when the evaluator returns the exact confirmed signature. + pub fn minimize( + self, + scenario: IdentityScenario, + signature: IdentityFailureSignature, + evaluator: &mut F, + ) -> Result + where + F: FnMut(&IdentityScenario) -> Result, String>, + { + if evaluator(&scenario).map_err(IdentityCorpusError::Evaluator)? != Some(signature.clone()) + { + return Err(IdentityCorpusError::InputSignatureMismatch); + } + let mut best = scenario; + let mut attempts = Vec::new(); + let mut index = best.actions().len(); + let mut exhausted = false; + while index > 0 { + if u64::try_from(attempts.len()).map_err(|_| IdentityCorpusError::ArithmeticOverflow)? + >= self.max_attempts + { + exhausted = true; + break; + } + index -= 1; + if best.actions().len() == 1 { + break; + } + let removed = best.actions()[index].id().to_owned(); + let mut actions = best.actions().to_vec(); + actions.remove(index); + let candidate = IdentityScenario::new(best.id().to_owned(), actions)?; + let digest = blake3::hash(&candidate.to_canonical_json()?) + .to_hex() + .to_string(); + let observed = evaluator(&candidate).map_err(IdentityCorpusError::Evaluator)?; + let accepted = observed.as_ref() == Some(&signature); + let ordinal = u64::try_from(attempts.len()) + .map_err(|_| IdentityCorpusError::ArithmeticOverflow)?; + attempts.push(IdentityMinimizationAttempt { + ordinal, + removed_action: removed, + candidate_digest: digest, + accepted, + observed_signature: observed, + }); + if accepted { + best = candidate; + index = index.min(best.actions().len()); + } + } + Ok(IdentityMinimizationResult { + scenario: best, + signature, + attempts, + exhausted, + }) + } +} + +/// Same-seed exact confirmation of both the original and minimized terminal failure. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityFailureConfirmation { + pub signature: IdentityFailureSignature, + pub root_seed: String, + pub original_scenario_digest: String, + pub minimized_scenario_digest: String, + pub original_report_digest: String, + pub original_raw_trace_digest: String, + pub original_normalized_trace_digest: String, + pub minimized_report_digest: String, + pub minimized_raw_trace_digest: String, + pub minimized_normalized_trace_digest: String, + pub original_confirmations: u8, + pub minimized_confirmations: u8, +} + +impl IdentityFailureConfirmation { + /// Requires two byte-exact real-run confirmations for each terminal candidate. + pub fn new( + original_scenario: &IdentityScenario, + minimized_scenario: &IdentityScenario, + original_first: &IdentityFailedRunRecord, + original_second: &IdentityFailedRunRecord, + minimized_first: &IdentityFailedRunRecord, + minimized_second: &IdentityFailedRunRecord, + ) -> Result { + require_exact_failed_run_pair(original_first, original_second)?; + require_exact_failed_run_pair(minimized_first, minimized_second)?; + let signature = original_first.signature()?; + if minimized_first.signature()? != signature + || original_first.root_seed != minimized_first.root_seed + || original_scenario.id() != minimized_scenario.id() + || original_first.report.scenario_id != original_scenario.id() + || minimized_first.report.scenario_id != minimized_scenario.id() + { + return Err(IdentityCorpusError::ConfirmationMismatch); + } + Ok(Self { + signature, + root_seed: encode_seed(original_first.root_seed), + original_scenario_digest: digest(&original_scenario.to_canonical_json()?), + minimized_scenario_digest: digest(&minimized_scenario.to_canonical_json()?), + original_report_digest: digest(&canonical_json(&original_first.report)?), + original_raw_trace_digest: digest(&raw_trace_bytes(&original_first.trace)?), + original_normalized_trace_digest: digest(&normalized_trace_bytes( + &original_first.trace, + )?), + minimized_report_digest: digest(&canonical_json(&minimized_first.report)?), + minimized_raw_trace_digest: digest(&raw_trace_bytes(&minimized_first.trace)?), + minimized_normalized_trace_digest: digest(&normalized_trace_bytes( + &minimized_first.trace, + )?), + original_confirmations: 2, + minimized_confirmations: 2, + }) + } +} + +/// Serialized terminal evidence retained beside the minimized scenario and traces. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityFailureReport { + pub root_seed: String, + pub evidence: super::IdentityFailureEvidence, + pub report: IdentityRunReport, +} + +/// Integrity commit marker written last for one immutable identity failure bundle. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityFailureArtifactIndex { + pub schema_version: u16, + pub files: BTreeMap, +} + +/// Immutable persistence inputs for one replay-confirmed minimized identity failure. +#[derive(Debug)] +pub struct IdentityFailureArtifactBundle<'a> { + pub original: &'a IdentityScenario, + pub minimized: &'a IdentityMinimizationResult, + pub manifest: &'a RunManifest, + pub original_failure: &'a IdentityFailedRunRecord, + pub minimized_failure: &'a IdentityFailedRunRecord, + pub confirmation: &'a IdentityFailureConfirmation, +} + +impl IdentityFailureArtifactBundle<'_> { + /// Publishes all source-bound evidence and writes the integrity index last. + pub fn write( + &self, + store: &ArtifactStore, + ) -> Result { + self.validate_binding()?; + let scenario = self.minimized.scenario.to_canonical_json()?; + let original_report = IdentityFailureReport { + root_seed: encode_seed(self.original_failure.root_seed), + evidence: self.original_failure.evidence.clone(), + report: self.original_failure.report.clone(), + }; + let minimized_report = IdentityFailureReport { + root_seed: encode_seed(self.minimized_failure.root_seed), + evidence: self.minimized_failure.evidence.clone(), + report: self.minimized_failure.report.clone(), + }; + let manifest = self + .manifest + .to_canonical_json() + .map_err(|error| IdentityCorpusError::Encoding(error.to_string()))?; + let mut files = BTreeMap::new(); + for (name, bytes) in [ + ("manifest.json", manifest), + ("scenario.json", scenario.clone()), + ("failure-original.json", self.original.to_canonical_json()?), + ("failure-minimized.json", scenario), + ( + "failure-signature.json", + canonical_json(&self.minimized.signature)?, + ), + ("failure-minimization.json", canonical_json(self.minimized)?), + ( + "failure-confirmation.json", + canonical_json(self.confirmation)?, + ), + ( + "identity-failure-original-report.json", + canonical_json(&original_report)?, + ), + ( + "identity-failure-report.json", + canonical_json(&minimized_report)?, + ), + ( + "trace-original.raw.jsonl", + raw_trace_bytes(&self.original_failure.trace)?, + ), + ( + "trace-original.jsonl", + normalized_trace_bytes(&self.original_failure.trace)?, + ), + ( + "trace.raw.jsonl", + raw_trace_bytes(&self.minimized_failure.trace)?, + ), + ( + "trace.jsonl", + normalized_trace_bytes(&self.minimized_failure.trace)?, + ), + ] { + write_indexed(store, &mut files, name, &bytes)?; + } + let index = IdentityFailureArtifactIndex { + schema_version: IDENTITY_FAILURE_ARTIFACT_SCHEMA_VERSION, + files, + }; + store + .write_atomic("failure-artifacts.json", &canonical_json(&index)?) + .map_err(|error| IdentityCorpusError::Artifact(error.to_string()))?; + Ok(index) + } + + fn validate_binding(&self) -> Result<(), IdentityCorpusError> { + self.manifest + .validate() + .map_err(|error| IdentityCorpusError::Manifest(error.to_string()))?; + self.minimized.signature.validate()?; + let minimized_bytes = self.minimized.scenario.to_canonical_json()?; + if self.original.id() != self.minimized.scenario.id() + || self.original_failure.report.scenario_id != self.original.id() + || self.minimized_failure.report.scenario_id != self.minimized.scenario.id() + || self.manifest.scenario_id != self.minimized.scenario.id() + || self.manifest.scenario_hash != digest(&minimized_bytes) + || self.manifest.root_seed != encode_seed(self.original_failure.root_seed) + || self.manifest.root_seed != encode_seed(self.minimized_failure.root_seed) + || self.original_failure.signature()? != self.minimized.signature + || self.minimized_failure.signature()? != self.minimized.signature + || self.confirmation.signature != self.minimized.signature + || self.confirmation.root_seed != self.manifest.root_seed + || self.confirmation.original_scenario_digest + != digest(&self.original.to_canonical_json()?) + || self.confirmation.minimized_scenario_digest != digest(&minimized_bytes) + || self.confirmation.original_report_digest + != digest(&canonical_json(&self.original_failure.report)?) + || self.confirmation.original_raw_trace_digest + != digest(&raw_trace_bytes(&self.original_failure.trace)?) + || self.confirmation.original_normalized_trace_digest + != digest(&normalized_trace_bytes(&self.original_failure.trace)?) + || self.confirmation.minimized_report_digest + != digest(&canonical_json(&self.minimized_failure.report)?) + || self.confirmation.minimized_raw_trace_digest + != digest(&raw_trace_bytes(&self.minimized_failure.trace)?) + || self.confirmation.minimized_normalized_trace_digest + != digest(&normalized_trace_bytes(&self.minimized_failure.trace)?) + || self.confirmation.original_confirmations != 2 + || self.confirmation.minimized_confirmations != 2 + || self.minimized.attempts.iter().any(|attempt| { + attempt.accepted + && attempt.observed_signature.as_ref() != Some(&self.minimized.signature) + }) + { + return Err(IdentityCorpusError::ArtifactBindingMismatch); + } + Ok(()) + } +} + +/// Verifies the exact committed file set and every immutable artifact digest. +pub fn verify_identity_failure_artifacts( + root: &Path, +) -> Result { + let index_bytes = read_file(root.join("failure-artifacts.json"))?; + let index: IdentityFailureArtifactIndex = serde_json::from_slice(&index_bytes) + .map_err(|error| IdentityCorpusError::Encoding(error.to_string()))?; + if index.schema_version != IDENTITY_FAILURE_ARTIFACT_SCHEMA_VERSION { + return Err(IdentityCorpusError::InvalidArtifactIndex); + } + let required = BTreeSet::from([ + "failure-confirmation.json".to_owned(), + "failure-minimization.json".to_owned(), + "failure-minimized.json".to_owned(), + "failure-original.json".to_owned(), + "failure-signature.json".to_owned(), + "identity-failure-original-report.json".to_owned(), + "identity-failure-report.json".to_owned(), + "manifest.json".to_owned(), + "scenario.json".to_owned(), + "trace-original.jsonl".to_owned(), + "trace-original.raw.jsonl".to_owned(), + "trace.jsonl".to_owned(), + "trace.raw.jsonl".to_owned(), + ]); + if index.files.len() != required.len() + || !index.files.keys().all(|name| required.contains(name)) + { + return Err(IdentityCorpusError::InvalidArtifactIndex); + } + let mut actual = collect_files(root)?; + if !actual.remove("failure-artifacts.json") || actual != required { + return Err(IdentityCorpusError::UnenumeratedFiles); + } + for (name, expected) in &index.files { + if !valid_digest(expected) || digest(&read_file(root.join(name))?) != *expected { + return Err(IdentityCorpusError::ArtifactDigestMismatch(name.clone())); + } + } + Ok(index) +} + +/// Writes an unreviewed, replay-confirmed candidate for explicit human corpus promotion. +pub fn write_identity_promotion_candidate( + failure_root: &Path, + store: &ArtifactStore, + issue: impl Into, +) -> Result { + let issue = issue.into(); + let index = verify_identity_failure_artifacts(failure_root)?; + let manifest = RunManifest::from_json(&read_file(failure_root.join("manifest.json"))?) + .map_err(|error| IdentityCorpusError::Manifest(error.to_string()))?; + let scenario = IdentityScenario::from_json(&read_file(failure_root.join("scenario.json"))?)?; + let signature = IdentityFailureSignature::from_json(&read_file( + failure_root.join("failure-signature.json"), + )?)?; + let promotion = IdentityCorpusPromotionEvidence { + artifact_index_digest: digest(&canonical_json(&index)?), + source_revision: manifest.source.revision.clone(), + issue, + replay_confirmed: true, + signature_preserving: true, + }; + if !promotion.is_valid() + || manifest.scenario_id != scenario.id() + || manifest.scenario_hash != digest(&scenario.to_canonical_json()?) + { + return Err(IdentityCorpusError::ArtifactBindingMismatch); + } + let entry = IdentityCorpusEntry { + id: scenario.id().to_owned(), + scenario_file: "scenario.json".to_owned(), + seed: manifest.root_seed, + expectation: IdentityCorpusExpectation::ExpectedFailure { signature }, + promotion: Some(promotion), + reviewed: false, + }; + store + .write_atomic("scenario.json", &scenario.to_canonical_json()?) + .map_err(|error| IdentityCorpusError::Artifact(error.to_string()))?; + store + .write_atomic("entry.json", &canonical_json(&entry)?) + .map_err(|error| IdentityCorpusError::Artifact(error.to_string()))?; + Ok(entry) +} + +fn write_indexed( + store: &ArtifactStore, + files: &mut BTreeMap, + name: &str, + bytes: &[u8], +) -> Result<(), IdentityCorpusError> { + store + .write_atomic(name, bytes) + .map_err(|error| IdentityCorpusError::Artifact(error.to_string()))?; + files.insert(name.to_owned(), digest(bytes)); + Ok(()) +} + +fn require_exact_failed_run_pair( + first: &IdentityFailedRunRecord, + second: &IdentityFailedRunRecord, +) -> Result<(), IdentityCorpusError> { + if first != second || first.signature()? != second.signature()? { + return Err(IdentityCorpusError::ConfirmationMismatch); + } + Ok(()) +} + +fn canonical_json(value: &(impl Serialize + ?Sized)) -> Result, IdentityCorpusError> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| IdentityCorpusError::Encoding(error.to_string()))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn raw_trace_bytes(trace: &[TraceEvent]) -> Result, IdentityCorpusError> { + let mut bytes = Vec::new(); + for event in trace { + bytes.extend( + serde_json::to_vec(event) + .map_err(|error| IdentityCorpusError::Encoding(error.to_string()))?, + ); + bytes.push(b'\n'); + } + Ok(bytes) +} + +fn normalized_trace_bytes(trace: &[TraceEvent]) -> Result, IdentityCorpusError> { + let mut bytes = Vec::new(); + for event in trace { + bytes.extend( + normalized_trace_json(event) + .map_err(|error| IdentityCorpusError::Encoding(error.to_string()))?, + ); + bytes.push(b'\n'); + } + Ok(bytes) +} + +fn digest(bytes: &[u8]) -> String { + blake3::hash(bytes).to_hex().to_string() +} + +fn encode_seed(seed: [u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(64); + for byte in seed { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +fn collect_files(root: &Path) -> Result, IdentityCorpusError> { + let mut files = BTreeSet::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + if !entry.file_type()?.is_file() || files.len() > MAX_IDENTITY_CORPUS_ENTRIES { + return Err(IdentityCorpusError::UnenumeratedFiles); + } + let name = entry + .file_name() + .into_string() + .map_err(|_| IdentityCorpusError::UnenumeratedFiles)?; + files.insert(name); + } + Ok(files) +} + +fn valid_filename(value: &str) -> bool { + let path = PathBuf::from(value); + let mut components = path.components(); + matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none() + && value.ends_with(".json") + && value != "manifest.json" +} + +fn valid_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn valid_revision(value: &str) -> bool { + matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn decode_seed(value: &str) -> Result<[u8; 32], ()> { + if value.len() != 64 { + return Err(()); + } + let mut seed = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = nibble(pair[0]).ok_or(())?; + let low = nibble(pair[1]).ok_or(())?; + seed[index] = high + .checked_mul(16) + .and_then(|high| high.checked_add(low)) + .ok_or(())?; + } + Ok(seed) +} + +const fn nibble(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + } +} + +/// Strict corpus, reduction, or failure-persistence error. +#[derive(Debug, thiserror::Error)] +pub enum IdentityCorpusError { + #[error("identity corpus I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("identity corpus encoding failed: {0}")] + Encoding(String), + #[error("identity corpus manifest is invalid")] + InvalidManifest, + #[error("identity corpus entry {0} is invalid or unreviewed")] + InvalidEntry(String), + #[error("identity corpus seed for {0} is invalid or duplicated")] + InvalidSeed(String), + #[error("identity corpus entry {metadata} names scenario {scenario}")] + IdMismatch { metadata: String, scenario: String }, + #[error("identity corpus contains an undeclared or missing file")] + UnenumeratedFiles, + #[error("identity corpus does not cover the complete Lane A matrix: {0:?}")] + IncompleteCoverage(IdentityCoverage), + #[error("identity corpus invariant counters are incomplete for {0}")] + InvariantAccounting(String), + #[error("identity corpus terminal did not match the reviewed expectation for {0}")] + TerminalMismatch(String), + /// A corpus entry reached an expected-rejection terminal not represented by corpus metadata. + #[error("identity corpus entry {0} reached an undeclared expected model rejection")] + UnexpectedExpectedRejection(String), + #[error("identity failure signature is invalid")] + InvalidSignature, + #[error("identity minimization budget is invalid")] + InvalidMinimizationBudget, + #[error("identity minimizer input does not reproduce the exact signature")] + InputSignatureMismatch, + #[error("identity minimizer evaluator failed: {0}")] + Evaluator(String), + #[error("identity minimizer arithmetic overflow")] + ArithmeticOverflow, + #[error("identity failure artifact write failed: {0}")] + Artifact(String), + #[error("identity failure manifest is invalid: {0}")] + Manifest(String), + #[error("identity failure confirmations are not byte exact")] + ConfirmationMismatch, + #[error("identity failure artifact binding is inconsistent")] + ArtifactBindingMismatch, + #[error("identity failure artifact index is invalid")] + InvalidArtifactIndex, + #[error("identity failure artifact digest mismatch for {0}")] + ArtifactDigestMismatch(String), + #[error(transparent)] + Scenario(#[from] IdentityScenarioError), +} diff --git a/krikos-sim/src/identity/formal.rs b/krikos-sim/src/identity/formal.rs new file mode 100644 index 00000000000..2c763ee872e --- /dev/null +++ b/krikos-sim/src/identity/formal.rs @@ -0,0 +1,1271 @@ +//! Hermetic bounded exploration of the abstract account-control state machine. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use serde::{Deserialize, Serialize}; + +/// Hard state bound for the hermetic model checker. +pub const MAX_FORMAL_STATES: u64 = 4_096; +/// Hard attempted-transition bound for the hermetic model checker. +pub const MAX_FORMAL_TRANSITIONS: u64 = 200_000; +const CONTROLLER_MASK: u8 = 0b0000_0111; +const MAX_CONTROLLER_WEIGHT: u8 = 2; +const MAX_POLICY_WEIGHT: u8 = 4; +const MAX_FORMAL_ATTEMPTS_PER_STATE: usize = 128; + +/// Six required account-control properties. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FormalProperty { + RevokedControllersCannotAuthorize, + PolicyChangesUsePreviousPolicy, + ForksAreDetectable, + ThresholdRequirementsPreserved, + RecoveryDoesNotRetainOldControllers, + AcceptedEventsHaveUniquePredecessor, +} + +impl FormalProperty { + const ALL: [Self; 6] = [ + Self::RevokedControllersCannotAuthorize, + Self::PolicyChangesUsePreviousPolicy, + Self::ForksAreDetectable, + Self::ThresholdRequirementsPreserved, + Self::RecoveryDoesNotRetainOldControllers, + Self::AcceptedEventsHaveUniquePredecessor, + ]; +} + +/// Concrete counterexample mutation used to prove each checker is live independently. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FormalMutation { + RevokedControllerAuthorizes, + PolicyAuthorizesItself, + ForkIsHidden, + ThresholdBecomesUnsatisfied, + RecoveryRetainsOldController, + AcceptedEventHasTwoPredecessors, + RecoveryHidesFork, +} + +/// Property-specific non-vacuity evidence from the bounded transition relation. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FormalPropertyEvidence { + /// Every explored attempt for which the property predicate was evaluated. + pub evaluations: u64, + /// Attempts from a reachable state where the property's antecedent is true. + pub antecedent_witnesses: u64, + /// Accepted transitions relevant to the property. + pub accepted_witnesses: u64, + /// Rejected adversarial attempts relevant to the property. + pub rejected_witnesses: u64, +} + +/// Nonzero bounded exploration evidence and per-property witness counts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FormalCheckReport { + pub schema_version: u16, + pub states_explored: u64, + pub transitions_explored: u64, + pub maximum_depth: u8, + pub property_checks: BTreeMap, + pub property_evidence: BTreeMap, + pub tla_actions_validated: u64, + pub tla_properties_validated: u64, + pub semantic_parity_cases: u64, + pub asymmetric_weight_witnesses: u64, + pub transition_mutations_rejected: u64, + pub portable_mutations_rejected: u64, +} + +impl FormalCheckReport { + /// Reports true only for a non-vacuous complete six-property run. + pub fn is_non_vacuous(&self) -> bool { + self.states_explored > 0 + && self.transitions_explored > 0 + && self.tla_actions_validated == 6 + && self.tla_properties_validated == 6 + && self.semantic_parity_cases > 0 + && self.asymmetric_weight_witnesses > 0 + && self.transition_mutations_rejected == 7 + && self.portable_mutations_rejected == 2 + && FormalProperty::ALL.iter().all(|property| { + self.property_evidence + .get(property) + .is_some_and(|evidence| { + evidence.evaluations == self.transitions_explored + && evidence.antecedent_witnesses > 0 + && evidence.accepted_witnesses > 0 + && evidence.rejected_witnesses > 0 + }) + }) + } + + /// Canonical machine-readable command output. + pub fn to_canonical_json(&self) -> Result, FormalCheckError> { + let mut bytes = serde_json::to_vec_pretty(self) + .map_err(|error| FormalCheckError::Encoding(error.to_string()))?; + bytes.push(b'\n'); + Ok(bytes) + } +} + +/// Runs exhaustive breadth-first exploration within fixed controller and fork bounds. +pub fn check_account_control_model() -> Result { + let specification = validate_checked_in_specification()?; + let semantic_parity_cases = check_semantic_parity()?; + let asymmetric_weight_witnesses = check_asymmetric_weight_witnesses()?; + let transition_mutations_rejected = check_transition_mutation_controls()?; + let portable_mutations_rejected = check_portable_mutation_controls()?; + let initial = initial_state(); + let mut seen = BTreeSet::from([initial]); + let mut queue = VecDeque::from([(initial, 0_u8)]); + let mut transitions = 0_u64; + let mut maximum_depth = 0_u8; + let mut evidence = FormalProperty::ALL + .into_iter() + .map(|property| (property, FormalPropertyEvidence::default())) + .collect::>(); + + while let Some((state, depth)) = queue.pop_front() { + maximum_depth = maximum_depth.max(depth); + for attempt in attempts(state) { + transitions = transitions + .checked_add(1) + .ok_or(FormalCheckError::ArithmeticOverflow)?; + if transitions > MAX_FORMAL_TRANSITIONS { + return Err(FormalCheckError::TransitionBoundExceeded); + } + let outcome = apply_attempt(state, attempt); + check_outcome(&outcome, &mut evidence)?; + if outcome.accepted && seen.insert(outcome.after) { + let states = + u64::try_from(seen.len()).map_err(|_| FormalCheckError::ArithmeticOverflow)?; + if states > MAX_FORMAL_STATES { + return Err(FormalCheckError::StateBoundExceeded); + } + queue.push_back((outcome.after, depth.saturating_add(1))); + } + } + } + + let report = FormalCheckReport { + schema_version: 2, + states_explored: u64::try_from(seen.len()) + .map_err(|_| FormalCheckError::ArithmeticOverflow)?, + transitions_explored: transitions, + maximum_depth, + property_checks: evidence + .iter() + .map(|(property, evidence)| (*property, evidence.evaluations)) + .collect(), + property_evidence: evidence, + tla_actions_validated: specification.actions, + tla_properties_validated: specification.properties, + semantic_parity_cases, + asymmetric_weight_witnesses, + transition_mutations_rejected, + portable_mutations_rejected, + }; + if !report.is_non_vacuous() { + return Err(FormalCheckError::Vacuous); + } + Ok(report) +} + +/// Applies one deliberate counterexample and requires the named property checker to reject it. +pub fn check_formal_mutation(mutation: FormalMutation) -> Result<(), FormalViolation> { + let (before, attempt) = mutation_fixture(mutation); + let outcome = apply_attempt_with_mutation(before, attempt, Some(mutation)); + let mut evidence = FormalProperty::ALL + .into_iter() + .map(|property| (property, FormalPropertyEvidence::default())) + .collect::>(); + check_outcome(&outcome, &mut evidence) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SpecificationEvidence { + actions: u64, + properties: u64, +} + +fn validate_checked_in_specification() -> Result { + const SOURCE: &str = include_str!("../../../docs/identity/AccountControl.tla"); + const FORBIDDEN_GHOSTS: [&str; 4] = [ + "revokedAuthorized", + "policyBypass", + "recoveryRetained", + "uniquePredecessor", + ]; + const VARIABLES: [&str; 15] = [ + "active", + "revoked", + "threshold", + "heads", + "forkVisible", + "lastAccepted", + "lastKind", + "lastApprovals", + "predecessorCount", + "priorActive", + "priorRevoked", + "priorThreshold", + "priorHeads", + "recoveryReplacement", + "recoveryPreviousActive", + ]; + const RECORD_FIELDS: [&str; 10] = [ + "lastAccepted'", + "lastKind'", + "lastApprovals'", + "predecessorCount'", + "priorActive'", + "priorRevoked'", + "priorThreshold'", + "priorHeads'", + "recoveryReplacement'", + "recoveryPreviousActive'", + ]; + const ACTIONS: [(&str, &[&str]); 6] = [ + ( + "AddController", + &[ + "Authorized(approvals)", + "active' = active \\cup {c}", + "RecordAccepted(\"AddController\", approvals, 1, {})", + ], + ), + ( + "RevokeController", + &[ + "Authorized(approvals)", + "WeightOf(active \\ {c}) >= threshold", + "revoked' = revoked \\cup {c}", + "RecordAccepted(\"RevokeController\", approvals, 1, {})", + ], + ), + ( + "ChangePolicy", + &[ + "Authorized(approvals)", + "newThreshold \\in 1..WeightOf(active)", + "threshold' = newThreshold", + "RecordAccepted(\"ChangePolicy\", approvals, 1, {})", + ], + ), + ( + "OpenFork", + &[ + "Authorized(approvals)", + "heads' = 2", + "forkVisible' = TRUE", + "RecordAccepted(\"OpenFork\", approvals, 1, {})", + ], + ), + ( + "ResolveFork", + &[ + "heads > 1", + "Authorized(approvals)", + "forkVisible' = FALSE", + "RecordAccepted(\"ResolveFork\", approvals, heads, {})", + ], + ), + ( + "Recover", + &[ + "heads = 1", + "replacement \\cap revoked = {}", + "active' = replacement", + "threshold' = newThreshold", + "revoked' = revoked \\cup (active \\ replacement)", + "RecordAccepted(\"Recover\", {}, 1, replacement)", + ], + ), + ]; + const PROPERTIES: [(&str, &[&str]); 6] = [ + ( + "RevokedControllersCannotAuthorize", + &["lastAccepted", "lastApprovals", "priorRevoked"], + ), + ( + "PolicyChangesUsePreviousPolicy", + &["lastKind", "WeightOf(lastApprovals)", "priorThreshold"], + ), + ( + "ForksAreDetectable", + &["heads", "forkVisible", "priorHeads", "ResolveFork"], + ), + ( + "ThresholdRequirementsPreserved", + &["threshold", "WeightOf(active)", "active \\cap revoked"], + ), + ( + "RecoveryDoesNotRetainOldControllers", + &[ + "lastKind", + "recoveryReplacement", + "recoveryPreviousActive", + "revoked", + ], + ), + ( + "AcceptedEventsHaveUniquePredecessor", + &["lastAccepted", "lastKind", "predecessorCount", "priorHeads"], + ), + ]; + + if FORBIDDEN_GHOSTS.iter().any(|name| SOURCE.contains(name)) { + return Err(FormalCheckError::Specification( + "TLA+ specification retains a fixed truth-value ghost variable".to_owned(), + )); + } + let constants = definition_prefix(SOURCE, "CONSTANTS", "ASSUME")?; + require_tokens("CONSTANTS", constants, &["Controllers", "ControllerWeight"])?; + let assumptions = definition_prefix(SOURCE, "ASSUME", "VARIABLES")?; + require_tokens( + "ASSUME", + assumptions, + &[ + "Cardinality(Controllers) = 3", + "ControllerWeight \\in [Controllers -> 1..2]", + "ControllerWeight[c] = 2", + "ControllerWeight[c] = 1", + ], + )?; + let weighted = definition_body(SOURCE, "WeightOf")?; + require_tokens( + "WeightOf", + weighted, + &["ControllerWeight[c]", "WeightOf(controllerSet \\ {c})"], + )?; + let variables = definition_prefix(SOURCE, "VARIABLES", "vars ==")?; + require_tokens("VARIABLES", variables, &VARIABLES)?; + let record = definition_body(SOURCE, "RecordAccepted")?; + require_tokens("RecordAccepted", record, &RECORD_FIELDS)?; + let authorized = definition_body(SOURCE, "Authorized")?; + require_tokens( + "Authorized", + authorized, + &[ + "approvals \\in SUBSET active", + "approvals \\cap revoked = {}", + "WeightOf(approvals) >= threshold", + ], + )?; + for (action, tokens) in ACTIONS { + require_tokens(action, definition_body(SOURCE, action)?, tokens)?; + } + for (property, tokens) in PROPERTIES { + require_tokens(property, definition_body(SOURCE, property)?, tokens)?; + } + let safety = definition_body(SOURCE, "Safety")?; + for (property, _) in PROPERTIES { + require_tokens("Safety", safety, &[property])?; + } + + Ok(SpecificationEvidence { + actions: u64::try_from(ACTIONS.len()).map_err(|_| FormalCheckError::ArithmeticOverflow)?, + properties: u64::try_from(PROPERTIES.len()) + .map_err(|_| FormalCheckError::ArithmeticOverflow)?, + }) +} + +fn definition_prefix<'a>( + source: &'a str, + start_marker: &str, + end_marker: &str, +) -> Result<&'a str, FormalCheckError> { + let start = source.find(start_marker).ok_or_else(|| { + FormalCheckError::Specification(format!("missing TLA+ section {start_marker}")) + })?; + let after_start = &source[start..]; + let end = after_start.find(end_marker).ok_or_else(|| { + FormalCheckError::Specification(format!("unterminated TLA+ section {start_marker}")) + })?; + Ok(&after_start[..end]) +} + +fn definition_body<'a>(source: &'a str, name: &str) -> Result<&'a str, FormalCheckError> { + let plain_marker = format!("{name} =="); + let parameterized_marker = format!("{name}("); + let start = source + .find(&plain_marker) + .or_else(|| source.find(¶meterized_marker)) + .ok_or_else(|| { + FormalCheckError::Specification(format!("missing TLA+ definition {name}")) + })?; + let definition = &source[start..]; + let separator = definition.find("==").ok_or_else(|| { + FormalCheckError::Specification(format!("invalid TLA+ definition {name}")) + })?; + let body = &definition[separator + 2..]; + let end = body.find("\n\n").ok_or_else(|| { + FormalCheckError::Specification(format!("unterminated TLA+ definition {name}")) + })?; + Ok(&body[..end]) +} + +fn require_tokens(section: &str, body: &str, tokens: &[&str]) -> Result<(), FormalCheckError> { + if let Some(missing) = tokens.iter().find(|token| !body.contains(**token)) { + return Err(FormalCheckError::Specification(format!( + "TLA+ section {section} is missing semantic clause {missing:?}" + ))); + } + Ok(()) +} + +fn check_semantic_parity() -> Result { + let initial = initial_state(); + let mut seen = BTreeSet::from([initial]); + let mut queue = VecDeque::from([initial]); + let mut cases = 0_u64; + while let Some(state) = queue.pop_front() { + for attempt in attempts(state) { + let checker = apply_attempt(state, attempt); + let portable = apply_portable_spec_attempt(state, attempt); + cases = cases + .checked_add(1) + .ok_or(FormalCheckError::ArithmeticOverflow)?; + if checker != portable { + return Err(FormalCheckError::SemanticDivergence(format!( + "state={state:?}, attempt={attempt:?}, checker={checker:?}, portable={portable:?}" + ))); + } + if checker.accepted && seen.insert(checker.after) { + if seen.len() + > usize::try_from(MAX_FORMAL_STATES) + .map_err(|_| FormalCheckError::ArithmeticOverflow)? + { + return Err(FormalCheckError::StateBoundExceeded); + } + queue.push_back(checker.after); + } + } + } + if cases == 0 { + return Err(FormalCheckError::Vacuous); + } + Ok(cases) +} + +const fn initial_state() -> AbstractState { + AbstractState { + active: CONTROLLER_MASK, + revoked: 0, + threshold: 2, + heads: 1, + fork_visible: false, + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct AbstractState { + active: u8, + revoked: u8, + threshold: u8, + heads: u8, + fork_visible: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AbstractOperation { + Add { controller: u8 }, + Revoke { controller: u8 }, + ChangePolicy { threshold: u8 }, + OpenFork, + ResolveFork, + Recover { replacement: u8, threshold: u8 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Attempt { + approvals: u8, + predecessors: u8, + operation: AbstractOperation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Outcome { + before: AbstractState, + after: AbstractState, + attempt: Attempt, + accepted: bool, + recovery_replacement: u8, +} + +fn attempts(state: AbstractState) -> Vec { + let mut attempts = Vec::with_capacity(MAX_FORMAL_ATTEMPTS_PER_STATE); + for approvals in 0..=CONTROLLER_MASK { + for controller in 0..3 { + let bit = 1_u8 << controller; + if state.active & bit == 0 { + attempts.push(Attempt { + approvals, + predecessors: state.heads, + operation: AbstractOperation::Add { controller: bit }, + }); + } else { + attempts.push(Attempt { + approvals, + predecessors: state.heads, + operation: AbstractOperation::Revoke { controller: bit }, + }); + } + } + for threshold in 1..=MAX_POLICY_WEIGHT { + attempts.push(Attempt { + approvals, + predecessors: state.heads, + operation: AbstractOperation::ChangePolicy { threshold }, + }); + } + attempts.push(Attempt { + approvals, + predecessors: state.heads, + operation: if state.heads == 1 { + AbstractOperation::OpenFork + } else { + AbstractOperation::ResolveFork + }, + }); + } + for replacement in 1..=CONTROLLER_MASK { + for threshold in 1..=MAX_POLICY_WEIGHT { + attempts.push(Attempt { + approvals: 0, + predecessors: state.heads, + operation: AbstractOperation::Recover { + replacement, + threshold, + }, + }); + } + } + attempts.push(Attempt { + approvals: state.active, + predecessors: if state.heads == 1 { 2 } else { 1 }, + operation: AbstractOperation::ChangePolicy { + threshold: state.threshold, + }, + }); + assert!( + attempts.len() <= MAX_FORMAL_ATTEMPTS_PER_STATE, + "bounded formal attempt inventory must fit MAX_FORMAL_ATTEMPTS_PER_STATE" + ); + attempts +} + +fn apply_attempt(before: AbstractState, attempt: Attempt) -> Outcome { + apply_attempt_with_mutation(before, attempt, None) +} + +fn apply_attempt_with_mutation( + before: AbstractState, + attempt: Attempt, + mutation: Option, +) -> Outcome { + let is_recovery = matches!(attempt.operation, AbstractOperation::Recover { .. }); + let expected_predecessors = if matches!(attempt.operation, AbstractOperation::ResolveFork) { + before.heads + } else { + 1 + }; + let predecessor_valid = attempt.predecessors == expected_predecessors + || mutation == Some(FormalMutation::AcceptedEventHasTwoPredecessors); + let head_valid = match attempt.operation { + AbstractOperation::ResolveFork => before.heads == 2, + AbstractOperation::Recover { .. } + if mutation == Some(FormalMutation::RecoveryHidesFork) => + { + before.heads == 1 || before.heads == 2 + } + _ => before.heads == 1, + }; + let approvals_are_known = if mutation == Some(FormalMutation::RevokedControllerAuthorizes) { + attempt.approvals & !(before.active | before.revoked) == 0 + } else { + attempt.approvals & !before.active == 0 + }; + let approvals_exclude_revoked = mutation == Some(FormalMutation::RevokedControllerAuthorizes) + || attempt.approvals & before.revoked == 0; + let required_weight = match attempt.operation { + AbstractOperation::ChangePolicy { threshold } + if mutation == Some(FormalMutation::PolicyAuthorizesItself) => + { + threshold + } + _ => before.threshold, + }; + let authorized = approvals_are_known + && approvals_exclude_revoked + && approval_weight(attempt.approvals) >= required_weight; + let operation_valid = match attempt.operation { + AbstractOperation::Add { controller } => { + controller != 0 + && controller & !CONTROLLER_MASK == 0 + && controller.count_ones() == 1 + && controller & (before.active | before.revoked) == 0 + } + AbstractOperation::Revoke { controller } => { + controller & before.active != 0 + && approval_weight(before.active & !controller) >= before.threshold + } + AbstractOperation::ChangePolicy { threshold } => { + threshold > 0 + && (threshold <= approval_weight(before.active) + || mutation == Some(FormalMutation::ThresholdBecomesUnsatisfied)) + } + AbstractOperation::OpenFork | AbstractOperation::ResolveFork => true, + AbstractOperation::Recover { + replacement, + threshold, + } => { + replacement != 0 + && replacement & !CONTROLLER_MASK == 0 + && replacement & before.revoked == 0 + && threshold > 0 + && threshold <= approval_weight(replacement) + } + }; + let accepted = + predecessor_valid && head_valid && operation_valid && (is_recovery || authorized); + let mut after = before; + let mut replacement = 0; + if accepted { + match attempt.operation { + AbstractOperation::Add { controller } => { + after.active |= controller; + } + AbstractOperation::Revoke { controller } => { + let candidate = before.active & !controller; + after.active = candidate; + after.revoked |= controller; + } + AbstractOperation::ChangePolicy { threshold } => { + after.threshold = threshold; + } + AbstractOperation::OpenFork => { + after.heads = 2; + after.fork_visible = mutation != Some(FormalMutation::ForkIsHidden); + } + AbstractOperation::ResolveFork => { + after.heads = 1; + after.fork_visible = false; + } + AbstractOperation::Recover { + replacement: candidate, + threshold, + } => { + replacement = candidate; + after.active = candidate; + if mutation == Some(FormalMutation::RecoveryRetainsOldController) { + after.active |= before.active & !candidate; + } + after.revoked |= before.active & !after.active; + after.threshold = threshold; + after.heads = 1; + after.fork_visible = false; + } + } + } + if !accepted { + after = before; + replacement = 0; + } + Outcome { + before, + after, + attempt, + accepted, + recovery_replacement: replacement, + } +} + +/// Separately encoded executable semantics for the checked-in TLA+ transition predicates. +/// +/// Keeping this evaluator structurally distinct from [`apply_attempt`] lets the bounded command +/// exhaustively compare accepted/rejected outcomes and successor states instead of relying on +/// source-marker checks alone. +fn apply_portable_spec_attempt(before: AbstractState, attempt: Attempt) -> Outcome { + apply_portable_spec_attempt_with_mutation(before, attempt, None) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PortableMutation { + AuthorizationUsesCardinality, + RecoveryResetsThreshold, +} + +fn apply_portable_spec_attempt_with_mutation( + before: AbstractState, + attempt: Attempt, + mutation: Option, +) -> Outcome { + let submitted_weight = if mutation == Some(PortableMutation::AuthorizationUsesCardinality) { + approval_cardinality(attempt.approvals) + } else { + approval_weight(attempt.approvals) + }; + let approvals_are_authorized = attempt.approvals & !before.active == 0 + && attempt.approvals & before.revoked == 0 + && submitted_weight >= before.threshold; + let expected_predecessors = if matches!(attempt.operation, AbstractOperation::ResolveFork) { + before.heads + } else { + 1 + }; + let operation_precondition = match attempt.operation { + AbstractOperation::Add { controller } => { + before.heads == 1 + && controller & CONTROLLER_MASK != 0 + && controller.count_ones() == 1 + && controller & (before.active | before.revoked) == 0 + } + AbstractOperation::Revoke { controller } => { + before.heads == 1 + && controller & before.active != 0 + && approval_weight(before.active & !controller) >= before.threshold + } + AbstractOperation::ChangePolicy { threshold } => { + before.heads == 1 && threshold > 0 && threshold <= approval_weight(before.active) + } + AbstractOperation::OpenFork => before.heads == 1, + AbstractOperation::ResolveFork => before.heads > 1, + AbstractOperation::Recover { + replacement, + threshold, + } => { + before.heads == 1 + && replacement != 0 + && replacement & !CONTROLLER_MASK == 0 + && replacement & before.revoked == 0 + && threshold > 0 + && threshold <= approval_weight(replacement) + } + }; + let recovery = matches!(attempt.operation, AbstractOperation::Recover { .. }); + let accepted = attempt.predecessors == expected_predecessors + && operation_precondition + && (recovery || approvals_are_authorized); + let mut after = before; + let mut recovery_replacement = 0; + if accepted { + match attempt.operation { + AbstractOperation::Add { controller } => after.active |= controller, + AbstractOperation::Revoke { controller } => { + after.active &= !controller; + after.revoked |= controller; + } + AbstractOperation::ChangePolicy { threshold } => after.threshold = threshold, + AbstractOperation::OpenFork => { + after.heads = 2; + after.fork_visible = true; + } + AbstractOperation::ResolveFork => { + after.heads = 1; + after.fork_visible = false; + } + AbstractOperation::Recover { + replacement, + threshold, + } => { + recovery_replacement = replacement; + after.active = replacement; + after.revoked |= before.active & !replacement; + after.threshold = if mutation == Some(PortableMutation::RecoveryResetsThreshold) { + 1 + } else { + threshold + }; + after.heads = 1; + after.fork_visible = false; + } + } + } + if !accepted { + after = before; + recovery_replacement = 0; + } + Outcome { + before, + after, + attempt, + accepted, + recovery_replacement, + } +} + +fn check_outcome( + outcome: &Outcome, + evidence: &mut BTreeMap, +) -> Result<(), FormalViolation> { + let policy_change = matches!( + outcome.attempt.operation, + AbstractOperation::ChangePolicy { .. } + ); + let recovery = matches!(outcome.attempt.operation, AbstractOperation::Recover { .. }); + let expected_predecessors = + if matches!(outcome.attempt.operation, AbstractOperation::ResolveFork) { + outcome.before.heads + } else { + 1 + }; + let threshold_adversarial = match outcome.attempt.operation { + AbstractOperation::Revoke { controller } => { + approval_weight(outcome.before.active & !controller) < outcome.before.threshold + } + AbstractOperation::ChangePolicy { threshold } => { + threshold == 0 || threshold > approval_weight(outcome.before.active) + } + AbstractOperation::Recover { + replacement, + threshold, + } => threshold == 0 || threshold > approval_weight(replacement), + _ => false, + }; + let rejected_policy_self_authorization = match outcome.attempt.operation { + AbstractOperation::ChangePolicy { threshold } => { + !outcome.accepted + && approval_weight(outcome.attempt.approvals) < outcome.before.threshold + && approval_weight(outcome.attempt.approvals) >= threshold + } + _ => false, + }; + let evaluations = [ + ( + FormalProperty::RevokedControllersCannotAuthorize, + !outcome.accepted || outcome.attempt.approvals & outcome.before.revoked == 0, + outcome.before.revoked != 0, + outcome.accepted && outcome.before.revoked != 0 && !recovery, + !outcome.accepted && outcome.attempt.approvals & outcome.before.revoked != 0, + ), + ( + FormalProperty::PolicyChangesUsePreviousPolicy, + !outcome.accepted + || !policy_change + || approval_weight(outcome.attempt.approvals) >= outcome.before.threshold, + policy_change, + outcome.accepted && policy_change, + rejected_policy_self_authorization, + ), + ( + FormalProperty::ForksAreDetectable, + (outcome.after.heads < 2 || outcome.after.fork_visible) + && (!outcome.accepted + || outcome.before.heads < 2 + || matches!(outcome.attempt.operation, AbstractOperation::ResolveFork)), + outcome.before.heads > 1 + || matches!( + outcome.attempt.operation, + AbstractOperation::OpenFork | AbstractOperation::ResolveFork + ), + outcome.accepted + && matches!( + outcome.attempt.operation, + AbstractOperation::OpenFork | AbstractOperation::ResolveFork + ), + !outcome.accepted + && outcome.before.heads > 1 + && !matches!(outcome.attempt.operation, AbstractOperation::ResolveFork), + ), + ( + FormalProperty::ThresholdRequirementsPreserved, + outcome.after.threshold > 0 + && outcome.after.threshold <= approval_weight(outcome.after.active) + && outcome.after.active & outcome.after.revoked == 0, + matches!( + outcome.attempt.operation, + AbstractOperation::Revoke { .. } + | AbstractOperation::ChangePolicy { .. } + | AbstractOperation::Recover { .. } + ), + outcome.accepted + && matches!( + outcome.attempt.operation, + AbstractOperation::Revoke { .. } + | AbstractOperation::ChangePolicy { .. } + | AbstractOperation::Recover { .. } + ), + !outcome.accepted && threshold_adversarial, + ), + ( + FormalProperty::RecoveryDoesNotRetainOldControllers, + !outcome.accepted + || !matches!(outcome.attempt.operation, AbstractOperation::Recover { .. }) + || (outcome.after.active == outcome.recovery_replacement + && outcome.before.active + & !outcome.recovery_replacement + & !outcome.after.revoked + == 0), + recovery, + outcome.accepted && recovery, + !outcome.accepted + && recovery + && match outcome.attempt.operation { + AbstractOperation::Recover { + replacement, + threshold, + } => { + replacement & outcome.before.revoked != 0 + || threshold == 0 + || threshold > approval_weight(replacement) + || outcome.before.heads > 1 + } + _ => false, + }, + ), + ( + FormalProperty::AcceptedEventsHaveUniquePredecessor, + !outcome.accepted || outcome.attempt.predecessors == expected_predecessors, + true, + outcome.accepted && outcome.attempt.predecessors == expected_predecessors, + !outcome.accepted && outcome.attempt.predecessors != expected_predecessors, + ), + ]; + for (property, satisfied, antecedent, accepted, rejected) in evaluations { + let property_evidence = evidence.get_mut(&property).ok_or_else(|| FormalViolation { + property, + witness: "missing property evidence".to_owned(), + })?; + property_evidence.evaluations = + property_evidence + .evaluations + .checked_add(1) + .ok_or_else(|| FormalViolation { + property, + witness: "property evaluation counter overflow".to_owned(), + })?; + if antecedent { + property_evidence.antecedent_witnesses = property_evidence + .antecedent_witnesses + .checked_add(1) + .ok_or_else(|| FormalViolation { + property, + witness: "property antecedent counter overflow".to_owned(), + })?; + } + if accepted { + property_evidence.accepted_witnesses = property_evidence + .accepted_witnesses + .checked_add(1) + .ok_or_else(|| FormalViolation { + property, + witness: "property accepted-witness counter overflow".to_owned(), + })?; + } + if rejected { + property_evidence.rejected_witnesses = property_evidence + .rejected_witnesses + .checked_add(1) + .ok_or_else(|| FormalViolation { + property, + witness: "property rejected-witness counter overflow".to_owned(), + })?; + } + if !satisfied { + return Err(FormalViolation { + property, + witness: format!("{outcome:?}"), + }); + } + } + Ok(()) +} + +fn mutation_fixture(mutation: FormalMutation) -> (AbstractState, Attempt) { + let base = initial_state(); + match mutation { + FormalMutation::RevokedControllerAuthorizes => ( + AbstractState { + active: 0b010, + revoked: 0b001, + threshold: 1, + ..base + }, + Attempt { + approvals: 0b001, + predecessors: 1, + operation: AbstractOperation::ChangePolicy { threshold: 1 }, + }, + ), + FormalMutation::PolicyAuthorizesItself => ( + base, + Attempt { + approvals: 0b010, + predecessors: 1, + operation: AbstractOperation::ChangePolicy { threshold: 1 }, + }, + ), + FormalMutation::ForkIsHidden => ( + base, + Attempt { + approvals: 0b001, + predecessors: 1, + operation: AbstractOperation::OpenFork, + }, + ), + FormalMutation::ThresholdBecomesUnsatisfied => ( + AbstractState { + active: 0b010, + threshold: 1, + ..base + }, + Attempt { + approvals: 0b010, + predecessors: 1, + operation: AbstractOperation::ChangePolicy { threshold: 2 }, + }, + ), + FormalMutation::RecoveryRetainsOldController => ( + base, + Attempt { + approvals: 0, + predecessors: 1, + operation: AbstractOperation::Recover { + replacement: 0b001, + threshold: 2, + }, + }, + ), + FormalMutation::AcceptedEventHasTwoPredecessors => ( + base, + Attempt { + approvals: 0b001, + predecessors: 2, + operation: AbstractOperation::ChangePolicy { threshold: 2 }, + }, + ), + FormalMutation::RecoveryHidesFork => ( + AbstractState { + heads: 2, + fork_visible: true, + ..base + }, + Attempt { + approvals: 0, + predecessors: 1, + operation: AbstractOperation::Recover { + replacement: 0b001, + threshold: 2, + }, + }, + ), + } +} + +fn check_transition_mutation_controls() -> Result { + const MUTATIONS: [(FormalMutation, FormalProperty); 7] = [ + ( + FormalMutation::RevokedControllerAuthorizes, + FormalProperty::RevokedControllersCannotAuthorize, + ), + ( + FormalMutation::PolicyAuthorizesItself, + FormalProperty::PolicyChangesUsePreviousPolicy, + ), + ( + FormalMutation::ForkIsHidden, + FormalProperty::ForksAreDetectable, + ), + ( + FormalMutation::ThresholdBecomesUnsatisfied, + FormalProperty::ThresholdRequirementsPreserved, + ), + ( + FormalMutation::RecoveryRetainsOldController, + FormalProperty::RecoveryDoesNotRetainOldControllers, + ), + ( + FormalMutation::AcceptedEventHasTwoPredecessors, + FormalProperty::AcceptedEventsHaveUniquePredecessor, + ), + ( + FormalMutation::RecoveryHidesFork, + FormalProperty::ForksAreDetectable, + ), + ]; + let mut rejected = 0_u64; + for (mutation, expected) in MUTATIONS { + match check_formal_mutation(mutation) { + Err(violation) if violation.property == expected => { + rejected = rejected + .checked_add(1) + .ok_or(FormalCheckError::ArithmeticOverflow)?; + } + result => { + return Err(FormalCheckError::MutationControl(format!( + "transition mutation {mutation:?} expected {expected:?}, got {result:?}" + ))); + } + } + } + Ok(rejected) +} + +fn check_asymmetric_weight_witnesses() -> Result { + assert_eq!( + approval_weight(0b001), + MAX_CONTROLLER_WEIGHT, + "bounded controller zero must carry the asymmetric weight-two authority" + ); + let base = initial_state(); + let low_pair = Attempt { + approvals: 0b110, + predecessors: 1, + operation: AbstractOperation::ChangePolicy { threshold: 2 }, + }; + let cases = [ + ( + Attempt { + approvals: 0b001, + ..low_pair + }, + true, + ), + (low_pair, true), + ( + Attempt { + approvals: 0b010, + ..low_pair + }, + false, + ), + ( + Attempt { + approvals: 0b010, + operation: AbstractOperation::ChangePolicy { threshold: 1 }, + ..low_pair + }, + false, + ), + ]; + let mut witnesses = 0_u64; + for (attempt, accepted) in cases { + let outcome = apply_attempt(base, attempt); + if outcome.accepted != accepted || apply_portable_spec_attempt(base, attempt) != outcome { + return Err(FormalCheckError::MutationControl(format!( + "asymmetric authorization witness failed: {attempt:?}" + ))); + } + witnesses = witnesses + .checked_add(1) + .ok_or(FormalCheckError::ArithmeticOverflow)?; + } + let revoke_state = AbstractState { + active: 0b011, + threshold: 2, + ..base + }; + let revoke = Attempt { + approvals: 0b001, + predecessors: 1, + operation: AbstractOperation::Revoke { controller: 0b001 }, + }; + let invalid_recovery = Attempt { + approvals: 0, + predecessors: 1, + operation: AbstractOperation::Recover { + replacement: 0b010, + threshold: 2, + }, + }; + for (state, attempt) in [(revoke_state, revoke), (base, invalid_recovery)] { + if apply_attempt(state, attempt).accepted + || apply_portable_spec_attempt(state, attempt).accepted + { + return Err(FormalCheckError::MutationControl(format!( + "retained weighted threshold witness failed: {attempt:?}" + ))); + } + witnesses = witnesses + .checked_add(1) + .ok_or(FormalCheckError::ArithmeticOverflow)?; + } + Ok(witnesses) +} + +fn check_portable_mutation_controls() -> Result { + let base = initial_state(); + let cases = [ + ( + PortableMutation::AuthorizationUsesCardinality, + base, + Attempt { + approvals: 0b001, + predecessors: 1, + operation: AbstractOperation::ChangePolicy { threshold: 2 }, + }, + FormalProperty::PolicyChangesUsePreviousPolicy, + ), + ( + PortableMutation::RecoveryResetsThreshold, + base, + Attempt { + approvals: 0, + predecessors: 1, + operation: AbstractOperation::Recover { + replacement: 0b110, + threshold: 2, + }, + }, + FormalProperty::ThresholdRequirementsPreserved, + ), + ]; + let mut rejected = 0_u64; + for (mutation, state, attempt, property) in cases { + let checker = apply_attempt(state, attempt); + let portable = apply_portable_spec_attempt_with_mutation(state, attempt, Some(mutation)); + if checker == portable { + return Err(FormalCheckError::MutationControl(format!( + "portable mutation {mutation:?} was not detected for {property:?}" + ))); + } + rejected = rejected + .checked_add(1) + .ok_or(FormalCheckError::ArithmeticOverflow)?; + } + Ok(rejected) +} + +const fn approval_weight(mask: u8) -> u8 { + ((mask & 1) * MAX_CONTROLLER_WEIGHT) + ((mask >> 1) & 1) + ((mask >> 2) & 1) +} + +const fn approval_cardinality(mask: u8) -> u8 { + (mask & 1) + ((mask >> 1) & 1) + ((mask >> 2) & 1) +} + +/// Concrete property violation with a bounded debug witness. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("formal property {property:?} failed: {witness}")] +pub struct FormalViolation { + pub property: FormalProperty, + pub witness: String, +} + +/// Hermetic bounded-check configuration, exhaustion, encoding, or property failure. +#[derive(Debug, thiserror::Error)] +pub enum FormalCheckError { + #[error("formal state bound exceeded")] + StateBoundExceeded, + #[error("formal transition bound exceeded")] + TransitionBoundExceeded, + #[error("formal exploration arithmetic overflow")] + ArithmeticOverflow, + #[error("formal exploration was vacuous")] + Vacuous, + #[error("checked-in TLA+ specification is invalid: {0}")] + Specification(String), + #[error("portable TLA+ semantics diverged from the bounded checker: {0}")] + SemanticDivergence(String), + #[error("formal mutation control failed: {0}")] + MutationControl(String), + #[error("formal report encoding failed: {0}")] + Encoding(String), + #[error(transparent)] + Violation(#[from] FormalViolation), +} diff --git a/krikos-sim/src/identity/mod.rs b/krikos-sim/src/identity/mod.rs new file mode 100644 index 00000000000..b6d2d1c1b65 --- /dev/null +++ b/krikos-sim/src/identity/mod.rs @@ -0,0 +1,48 @@ +//! Independent account-control model and deterministic identity simulation lane. + +mod adapter; +mod corpus; +mod formal; +mod model; +mod replay; +mod scenario; + +pub use adapter::{ + DifferentialCoverage, DifferentialError, DifferentialHistoryReport, + DifferentialProductionEvidence, DifferentialSnapshot, DifferentialStep, + run_differential_history, +}; +pub use corpus::{ + IDENTITY_CORPUS_SCHEMA_VERSION, IdentityCorpus, IdentityCorpusEntry, IdentityCorpusError, + IdentityCorpusExpectation, IdentityCorpusPromotionEvidence, IdentityCorpusReport, + IdentityFailureArtifactBundle, IdentityFailureArtifactIndex, IdentityFailureConfirmation, + IdentityFailureReport, IdentityFailureSignature, IdentityMinimizationAttempt, + IdentityMinimizationResult, IdentityMinimizer, LoadedIdentityCorpusEntry, + verify_identity_failure_artifacts, write_identity_promotion_candidate, +}; +pub use formal::{ + FormalCheckError, FormalCheckReport, FormalMutation, FormalProperty, FormalPropertyEvidence, + FormalViolation, MAX_FORMAL_STATES, MAX_FORMAL_TRANSITIONS, check_account_control_model, + check_formal_mutation, +}; +pub use model::{ + AccountControlModel, AccountModelSnapshot, ApplyDisposition, ControllerId, DeviceId, + DeviceLifecycle, EventId, ForkResolution, IdentityEvent, IdentityOperation, MigrationState, + ModelController, ModelError, ModelPolicy, RecoveryPlan, +}; +pub use replay::{ + IdentityArtifactBundle, IdentityRejectionArtifactBundle, IdentityRejectionReport, + IdentityReplayError, replay_identity_artifacts, replay_identity_failure_artifacts, + replay_identity_rejection_artifacts, +}; +pub use scenario::{ + ExpectedModelRejection, ForkScenarioOperation, IDENTITY_SCENARIO_SCHEMA_VERSION, + IdentityAction, IdentityActionExpectation, IdentityCoverage, IdentityDeliveryFault, + IdentityDeliveryReport, IdentityEnvironmentSnapshot, IdentityFailedRunRecord, + IdentityFailureClass, IdentityFailureEvidence, IdentityInvariantCounters, + IdentityInvariantMutation, IdentityRejectedRunRecord, IdentityRejectionClass, + IdentityRejectionEvidence, IdentityReplicaSnapshot, IdentityRunOutcome, IdentityRunRecord, + IdentityRunReport, IdentityScenario, IdentityScenarioAction, IdentityScenarioError, + IdentityScenarioRunner, IdentityStepReport, MAX_IDENTITY_ACTIONS, MAX_IDENTITY_SCENARIO_BYTES, + MigrationPhase, RecoveryController, +}; diff --git a/krikos-sim/src/identity/model.rs b/krikos-sim/src/identity/model.rs new file mode 100644 index 00000000000..ceb47b63ece --- /dev/null +++ b/krikos-sim/src/identity/model.rs @@ -0,0 +1,1033 @@ +//! Pure account-control reference model. +//! +//! This module deliberately has no dependency on the production identity implementation. It +//! models public authority state and transition rules independently so differential tests can +//! detect shared mistakes. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +/// Maximum controllers retained by one reference-model account. +pub const MAX_MODEL_CONTROLLERS: usize = 16; +/// Maximum devices retained by one reference-model account. +pub const MAX_MODEL_DEVICES: usize = 64; +/// Maximum accepted events retained by one bounded model history. +pub const MAX_MODEL_EVENTS: usize = 256; +/// Maximum competing heads accepted by one bounded fork descriptor. +pub const MAX_MODEL_FORK_HEADS: usize = 8; + +/// Stable model-owned controller identity. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct ControllerId(u16); + +impl ControllerId { + /// Creates a run-local controller identity. + pub const fn new(value: u16) -> Self { + Self(value) + } + + /// Returns the numeric identity. + pub const fn get(self) -> u16 { + self.0 + } +} + +/// Stable model-owned device identity. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct DeviceId(u16); + +impl DeviceId { + /// Creates a run-local device identity. + pub const fn new(value: u16) -> Self { + Self(value) + } + + /// Returns the numeric identity. + pub const fn get(self) -> u16 { + self.0 + } +} + +/// Stable model-owned event identity. Zero is the genesis predecessor anchor. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct EventId(u64); + +impl EventId { + /// Creates an event or genesis-anchor identity. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Returns the numeric identity. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Public controller record used by the independent model. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelController { + id: ControllerId, + weight: u16, +} + +impl ModelController { + /// Creates a controller with nonzero identity and weight. + pub fn new(id: ControllerId, weight: u16) -> Result { + if id.get() == 0 { + return Err(ModelError::ZeroIdentifier("controller")); + } + if weight == 0 { + return Err(ModelError::ZeroWeight); + } + Ok(Self { id, weight }) + } + + /// Controller identity. + pub const fn id(&self) -> ControllerId { + self.id + } + + /// Controller authorization weight. + pub const fn weight(&self) -> u16 { + self.weight + } +} + +/// Weighted policy evaluated against the complete pre-transition controller state. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModelPolicy { + required_weight: u16, +} + +impl ModelPolicy { + /// Creates a nonzero weighted threshold. + pub fn new(required_weight: u16) -> Result { + if required_weight == 0 { + return Err(ModelError::ZeroWeight); + } + Ok(Self { required_weight }) + } + + /// Weight required under this policy. + pub const fn required_weight(self) -> u16 { + self.required_weight + } +} + +/// Model device lifecycle. Revocation is permanent. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeviceLifecycle { + /// Device may receive current-epoch group keys. + Active, + /// Device is permanently tombstoned. + Revoked, +} + +/// Simplified protocol-signature migration state. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationState { + /// One current signature suite. + #[default] + Stable, + /// A new suite is staged and cross-signing is required. + Pending, + /// Old and new suites are both required. + Dual, + /// Only the replacement suite remains authoritative. + Complete, +} + +/// Recovery result that replaces account authority exactly. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RecoveryPlan { + controllers: Vec, + policy: ModelPolicy, +} + +impl RecoveryPlan { + /// Creates a bounded, distinct, threshold-satisfying replacement authority set. + pub fn new( + mut controllers: Vec, + policy: ModelPolicy, + ) -> Result { + normalize_controllers(&mut controllers)?; + validate_controller_set(&controllers, policy)?; + Ok(Self { + controllers, + policy, + }) + } + + /// Exact replacement controllers. + pub fn controllers(&self) -> &[ModelController] { + &self.controllers + } + + /// Exact replacement policy. + pub const fn policy(&self) -> ModelPolicy { + self.policy + } +} + +/// Closed operation set exercised by the independent model and formal checker. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum IdentityOperation { + /// Add one independently weighted controller. + AddController(ModelController), + /// Permanently revoke one controller. + RevokeController(ControllerId), + /// Replace the weighted policy. + ChangePolicy(ModelPolicy), + /// Authorize a new independently identified device. + AuthorizeDevice(DeviceId), + /// Permanently revoke a device. + RevokeDevice(DeviceId), + /// Replace authority through the separate recovery authorization path. + Recover(RecoveryPlan), + /// Stage recovery evidence without replacing authority yet. + BeginRecovery, + /// Stage a replacement signature suite. + BeginMigration, + /// Enter dual-signature migration. + ActivateMigration, + /// Retire the previous signature suite. + CompleteMigration, + /// Rotate the group key to exactly the currently active devices. + RotateGroupKey, +} + +impl IdentityOperation { + /// Return the exact v1 post-operation epoch for an account event. + /// + /// Migration begin is the bounded model's only non-advancing event. Group-key rotation remains + /// an advancing operation when represented as a scenario event; the differential adapter uses + /// [`AccountControlModel::rotate_group_key`] for the production implementation's out-of-band + /// application-key rotation. + pub fn resulting_epoch(&self, current_epoch: u64) -> Result { + if matches!(self, Self::BeginMigration) { + Ok(current_epoch) + } else { + current_epoch + .checked_add(1) + .ok_or(ModelError::ArithmeticOverflow("event epoch")) + } + } +} + +/// One model transition and its explicit prior-authority approvals. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityEvent { + id: EventId, + predecessor: EventId, + sequence: u64, + resulting_epoch: u64, + approvals: Vec, + operation: IdentityOperation, +} + +impl IdentityEvent { + /// Creates one bounded canonical event. + pub fn new( + id: EventId, + predecessor: EventId, + sequence: u64, + resulting_epoch: u64, + mut approvals: Vec, + operation: IdentityOperation, + ) -> Result { + if id.get() == 0 || id == predecessor { + return Err(ModelError::ZeroOrSelfEventId); + } + if sequence == 0 + || (resulting_epoch == 0 && !matches!(operation, IdentityOperation::BeginMigration)) + { + return Err(ModelError::InvalidSequence); + } + approvals.sort_unstable(); + if approvals.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(ModelError::DuplicateApproval); + } + if approvals.iter().any(|controller| controller.get() == 0) { + return Err(ModelError::ZeroIdentifier("approval controller")); + } + Ok(Self { + id, + predecessor, + sequence, + resulting_epoch, + approvals, + operation, + }) + } + + /// Event identity. + pub const fn id(&self) -> EventId { + self.id + } + + /// Exact unique predecessor claimed by this event. + pub const fn predecessor(&self) -> EventId { + self.predecessor + } + + /// Claimed next sequence. + pub const fn sequence(&self) -> u64 { + self.sequence + } + + /// Claimed next epoch. + pub const fn resulting_epoch(&self) -> u64 { + self.resulting_epoch + } + + /// Sorted distinct controller approvals. + pub fn approvals(&self) -> &[ControllerId] { + &self.approvals + } + + /// Requested account-control operation. + pub const fn operation(&self) -> &IdentityOperation { + &self.operation + } +} + +/// Explicit choose-one-branch fork resolution authorized by the common pre-fork authority. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ForkResolution { + id: EventId, + heads: Vec, + selected_head: EventId, + sequence: u64, + resulting_epoch: u64, + approvals: Vec, + revoked_controllers: Vec, + revoked_devices: Vec, +} + +impl ForkResolution { + /// Creates a canonical resolution that consumes every current head. + #[allow(clippy::too_many_arguments)] + pub fn new( + id: EventId, + mut heads: Vec, + selected_head: EventId, + sequence: u64, + resulting_epoch: u64, + mut approvals: Vec, + mut revoked_controllers: Vec, + mut revoked_devices: Vec, + ) -> Result { + if id.get() == 0 || sequence == 0 || resulting_epoch == 0 { + return Err(ModelError::InvalidForkResolution); + } + heads.sort_unstable(); + approvals.sort_unstable(); + revoked_controllers.sort_unstable(); + revoked_devices.sort_unstable(); + if heads.len() < 2 + || heads.len() > MAX_MODEL_FORK_HEADS + || heads.windows(2).any(|pair| pair[0] == pair[1]) + || heads.iter().any(|head| head.get() == 0 || *head == id) + || heads.binary_search(&selected_head).is_err() + || approvals.windows(2).any(|pair| pair[0] == pair[1]) + || revoked_controllers + .windows(2) + .any(|pair| pair[0] == pair[1]) + || revoked_devices.windows(2).any(|pair| pair[0] == pair[1]) + || approvals.iter().any(|controller| controller.get() == 0) + || revoked_controllers + .iter() + .any(|controller| controller.get() == 0) + || revoked_devices.iter().any(|device| device.get() == 0) + { + return Err(ModelError::InvalidForkResolution); + } + Ok(Self { + id, + heads, + selected_head, + sequence, + resulting_epoch, + approvals, + revoked_controllers, + revoked_devices, + }) + } + + /// Resolution event identity. + pub const fn id(&self) -> EventId { + self.id + } + + /// Complete sorted fork-head set consumed by this resolution. + pub fn heads(&self) -> &[EventId] { + &self.heads + } + + /// Branch selected by the resolution. + pub const fn selected_head(&self) -> EventId { + self.selected_head + } +} + +/// Whether an event advanced the selected branch, replayed, or exposed a fork. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApplyDisposition { + /// Linear state advanced once. + Applied, + /// Exact already-retained event was idempotently replayed. + Replay, + /// A valid sibling branch was retained and the account became forked. + ForkDetected, +} + +/// Stable observable state for differential comparison and replay artifacts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AccountModelSnapshot { + /// Stable account identity. + pub account_id: [u8; 32], + /// Selected linear sequence before any explicit fork resolution. + pub sequence: u64, + /// Selected linear security epoch. + pub epoch: u64, + /// Sorted current branch heads. + pub heads: Vec, + /// Whether more than one valid branch is retained. + pub forked: bool, + /// Sorted active controllers. + pub active_controllers: Vec, + /// Permanent controller tombstones. + pub revoked_controllers: Vec, + /// Complete sorted device lifecycle map. + pub devices: BTreeMap, + /// Current weighted policy. + pub policy: ModelPolicy, + /// Signature-suite migration state. + pub migration: MigrationState, + /// Monotonic group-key generation. + pub group_key_generation: u64, + /// Exact active-device recipients of the latest rotation. + pub group_key_recipients: Vec, +} + +#[derive(Clone, Debug)] +struct AuthorityView { + sequence: u64, + epoch: u64, + controllers: BTreeMap, + revoked_controllers: BTreeSet, + devices: BTreeMap, + policy: ModelPolicy, + migration: MigrationState, + group_key_generation: u64, + group_key_recipients: BTreeSet, +} + +impl AuthorityView { + fn apply_operation(&mut self, operation: &IdentityOperation) -> Result<(), ModelError> { + match operation { + IdentityOperation::AddController(controller) => self.add_controller(controller.clone()), + IdentityOperation::RevokeController(id) => self.revoke_controller(*id), + IdentityOperation::ChangePolicy(policy) => self.change_policy(*policy), + IdentityOperation::AuthorizeDevice(id) => self.authorize_device(*id), + IdentityOperation::RevokeDevice(id) => self.revoke_device(*id), + IdentityOperation::Recover(_) => Err(ModelError::RecoveryAuthorizationRequired), + IdentityOperation::BeginRecovery => Ok(()), + IdentityOperation::BeginMigration => { + if self.migration != MigrationState::Stable { + return Err(ModelError::InvalidMigration); + } + self.migration = MigrationState::Pending; + Ok(()) + } + IdentityOperation::ActivateMigration => { + if self.migration != MigrationState::Pending { + return Err(ModelError::InvalidMigration); + } + self.migration = MigrationState::Dual; + Ok(()) + } + IdentityOperation::CompleteMigration => { + if self.migration != MigrationState::Dual { + return Err(ModelError::InvalidMigration); + } + self.migration = MigrationState::Complete; + Ok(()) + } + IdentityOperation::RotateGroupKey => { + self.group_key_generation = self + .group_key_generation + .checked_add(1) + .ok_or(ModelError::ArithmeticOverflow("group-key generation"))?; + self.group_key_recipients = self + .devices + .iter() + .filter_map(|(id, lifecycle)| { + (*lifecycle == DeviceLifecycle::Active).then_some(*id) + }) + .collect(); + Ok(()) + } + } + } + + fn add_controller(&mut self, controller: ModelController) -> Result<(), ModelError> { + if self.controllers.len() >= MAX_MODEL_CONTROLLERS { + return Err(ModelError::LimitExceeded("controllers")); + } + if self.controllers.contains_key(&controller.id()) + || self.revoked_controllers.contains(&controller.id()) + { + return Err(ModelError::ControllerAlreadyKnown(controller.id())); + } + self.controllers.insert(controller.id(), controller); + Ok(()) + } + + fn revoke_controller(&mut self, id: ControllerId) -> Result<(), ModelError> { + let controller = self + .controllers + .remove(&id) + .ok_or(ModelError::UnknownController(id))?; + let remaining_weight = total_weight(self.controllers.values())?; + if remaining_weight < self.policy.required_weight() { + self.controllers.insert(id, controller); + return Err(ModelError::UnsatisfiedPolicy { + available: remaining_weight, + required: self.policy.required_weight(), + }); + } + self.revoked_controllers.insert(id); + Ok(()) + } + + fn change_policy(&mut self, policy: ModelPolicy) -> Result<(), ModelError> { + let available = total_weight(self.controllers.values())?; + if available < policy.required_weight() { + return Err(ModelError::UnsatisfiedPolicy { + available, + required: policy.required_weight(), + }); + } + self.policy = policy; + Ok(()) + } + + fn authorize_device(&mut self, id: DeviceId) -> Result<(), ModelError> { + if id.get() == 0 { + return Err(ModelError::ZeroIdentifier("device")); + } + if self.devices.len() >= MAX_MODEL_DEVICES { + return Err(ModelError::LimitExceeded("devices")); + } + if self.devices.contains_key(&id) { + return Err(ModelError::DeviceAlreadyKnown(id)); + } + self.devices.insert(id, DeviceLifecycle::Active); + Ok(()) + } + + fn revoke_device(&mut self, id: DeviceId) -> Result<(), ModelError> { + match self.devices.get_mut(&id) { + Some(lifecycle @ DeviceLifecycle::Active) => { + *lifecycle = DeviceLifecycle::Revoked; + self.group_key_recipients.remove(&id); + Ok(()) + } + Some(DeviceLifecycle::Revoked) => Err(ModelError::DeviceAlreadyKnown(id)), + None => Err(ModelError::UnknownDevice(id)), + } + } + + fn authorize(&self, approvals: &[ControllerId]) -> Result<(), ModelError> { + let mut total = 0_u16; + for approval in approvals { + if self.revoked_controllers.contains(approval) { + return Err(ModelError::RevokedController(*approval)); + } + let controller = self + .controllers + .get(approval) + .ok_or(ModelError::UnknownController(*approval))?; + total = total + .checked_add(controller.weight()) + .ok_or(ModelError::ArithmeticOverflow("approval weight"))?; + } + if total < self.policy.required_weight() { + return Err(ModelError::InsufficientWeight { + actual: total, + required: self.policy.required_weight(), + }); + } + Ok(()) + } + + fn recover(&mut self, plan: &RecoveryPlan) -> Result<(), ModelError> { + let replacement = plan + .controllers() + .iter() + .map(|controller| (controller.id(), controller.clone())) + .collect::>(); + for old in self.controllers.keys() { + if !replacement.contains_key(old) { + self.revoked_controllers.insert(*old); + } + } + if replacement + .keys() + .any(|id| self.revoked_controllers.contains(id)) + { + return Err(ModelError::RecoveryReintroducesRevokedController); + } + self.controllers = replacement; + self.policy = plan.policy(); + self.migration = MigrationState::Stable; + for lifecycle in self.devices.values_mut() { + *lifecycle = DeviceLifecycle::Revoked; + } + self.group_key_recipients.clear(); + Ok(()) + } + + fn apply_resolution_revocations( + &mut self, + controllers: &[ControllerId], + devices: &[DeviceId], + ) -> Result<(), ModelError> { + for id in controllers { + if self.revoked_controllers.contains(id) { + continue; + } + self.controllers + .remove(id) + .ok_or(ModelError::UnknownController(*id))?; + self.revoked_controllers.insert(*id); + } + validate_controller_set( + &self.controllers.values().cloned().collect::>(), + self.policy, + )?; + for id in devices { + match self.devices.get_mut(id) { + Some(lifecycle) => *lifecycle = DeviceLifecycle::Revoked, + None => return Err(ModelError::UnknownDevice(*id)), + } + self.group_key_recipients.remove(id); + } + Ok(()) + } +} + +/// Executable independent account-control state machine. +#[derive(Clone, Debug)] +pub struct AccountControlModel { + account_id: [u8; 32], + current_head: EventId, + heads: BTreeSet, + forked: bool, + selected: AuthorityView, + fork_common: Option, + views: BTreeMap, + events: BTreeMap, + resolutions: BTreeMap, +} + +impl AccountControlModel { + /// Creates a genesis model with a stable account identity and satisfiable authority. + pub fn new( + account_id: [u8; 32], + mut controllers: Vec, + policy: ModelPolicy, + ) -> Result { + normalize_controllers(&mut controllers)?; + validate_controller_set(&controllers, policy)?; + let selected = AuthorityView { + sequence: 0, + epoch: 0, + controllers: controllers + .into_iter() + .map(|controller| (controller.id(), controller)) + .collect(), + revoked_controllers: BTreeSet::new(), + devices: BTreeMap::new(), + policy, + migration: MigrationState::Stable, + group_key_generation: 0, + group_key_recipients: BTreeSet::new(), + }; + let genesis = EventId::new(0); + Ok(Self { + account_id, + current_head: genesis, + heads: BTreeSet::new(), + forked: false, + fork_common: None, + views: BTreeMap::from([(genesis, selected.clone())]), + selected, + events: BTreeMap::new(), + resolutions: BTreeMap::new(), + }) + } + + /// Applies an ordinary prior-policy-authorized transition atomically. + pub fn apply(&mut self, event: &IdentityEvent) -> Result { + self.apply_internal(event, false) + } + + /// Applies an event through the separate recovery authorization path. + pub fn apply_recovery( + &mut self, + event: &IdentityEvent, + ) -> Result { + self.apply_internal(event, true) + } + + /// Rotate application group-key material without adding an account-log event. + /// + /// Production performs the actual wrap generation as an effect bound to an already accepted + /// account revision. This method keeps that out-of-band operation from inventing an extra + /// sequence, epoch, head, or predecessor in differential histories. + pub fn rotate_group_key(&mut self) -> Result<(), ModelError> { + if self.forked { + return Err(ModelError::ForkedState); + } + self.selected + .apply_operation(&IdentityOperation::RotateGroupKey)?; + if let Some(view) = self.views.get_mut(&self.current_head) { + *view = self.selected.clone(); + } else { + return Err(ModelError::UnknownPredecessor(self.current_head)); + } + Ok(()) + } + + fn apply_internal( + &mut self, + event: &IdentityEvent, + recovery_authorized: bool, + ) -> Result { + if let Some(retained) = self.events.get(&event.id()) { + return if retained == event { + Ok(ApplyDisposition::Replay) + } else { + Err(ModelError::DuplicateEventId(event.id())) + }; + } + if self.resolutions.contains_key(&event.id()) { + return Err(ModelError::DuplicateEventId(event.id())); + } + if self + .events + .len() + .checked_add(self.resolutions.len()) + .ok_or(ModelError::ArithmeticOverflow("retained event count"))? + >= MAX_MODEL_EVENTS + { + return Err(ModelError::LimitExceeded("events")); + } + if self.forked { + return Err(ModelError::ForkedState); + } + let parent = self + .views + .get(&event.predecessor()) + .cloned() + .ok_or(ModelError::UnknownPredecessor(event.predecessor()))?; + validate_next_position(&parent, event)?; + + let mut candidate = parent.clone(); + match event.operation() { + IdentityOperation::Recover(plan) if recovery_authorized => { + if !event.approvals().is_empty() { + return Err(ModelError::RecoveryHasControllerApprovals); + } + candidate.recover(plan)?; + } + IdentityOperation::Recover(_) => return Err(ModelError::RecoveryAuthorizationRequired), + _ if recovery_authorized => return Err(ModelError::RecoveryOperationRequired), + operation => { + parent.authorize(event.approvals())?; + candidate.apply_operation(operation)?; + } + } + candidate.sequence = event.sequence(); + candidate.epoch = event.resulting_epoch(); + + if event.predecessor() != self.current_head { + self.events.insert(event.id(), event.clone()); + self.views.insert(event.id(), candidate); + if self.heads.is_empty() { + self.heads.insert(self.current_head); + } + self.heads.insert(event.id()); + self.selected = parent.clone(); + self.selected.sequence = event.sequence(); + self.fork_common = Some(parent); + self.forked = true; + return Ok(ApplyDisposition::ForkDetected); + } + + self.events.insert(event.id(), event.clone()); + self.views.insert(event.id(), candidate.clone()); + self.selected = candidate; + self.current_head = event.id(); + self.heads.clear(); + self.heads.insert(event.id()); + Ok(ApplyDisposition::Applied) + } + + /// Resolves the exact retained fork without silently merging branch state. + pub fn resolve_fork( + &mut self, + resolution: &ForkResolution, + ) -> Result { + if let Some(retained) = self.resolutions.get(&resolution.id) { + return if retained == resolution { + Ok(ApplyDisposition::Replay) + } else { + Err(ModelError::DuplicateEventId(resolution.id)) + }; + } + if self.events.contains_key(&resolution.id) { + return Err(ModelError::DuplicateEventId(resolution.id)); + } + if !self.forked + || resolution.heads.as_slice() != self.heads.iter().copied().collect::>() + { + return Err(ModelError::InvalidForkResolution); + } + let common = self + .fork_common + .as_ref() + .ok_or(ModelError::InvalidForkResolution)?; + common.authorize(&resolution.approvals)?; + let maximum_sequence = resolution + .heads + .iter() + .map(|head| self.views.get(head).map(|view| view.sequence)) + .collect::>>() + .ok_or(ModelError::InvalidForkResolution)? + .into_iter() + .max() + .ok_or(ModelError::InvalidForkResolution)?; + let maximum_epoch = resolution + .heads + .iter() + .map(|head| self.views.get(head).map(|view| view.epoch)) + .collect::>>() + .ok_or(ModelError::InvalidForkResolution)? + .into_iter() + .max() + .ok_or(ModelError::InvalidForkResolution)?; + if maximum_sequence.checked_add(1) != Some(resolution.sequence) + || maximum_epoch.checked_add(1) != Some(resolution.resulting_epoch) + { + return Err(ModelError::InvalidSequence); + } + let mut candidate = self + .views + .get(&resolution.selected_head) + .cloned() + .ok_or(ModelError::InvalidForkResolution)?; + candidate.apply_resolution_revocations( + &resolution.revoked_controllers, + &resolution.revoked_devices, + )?; + candidate.sequence = resolution.sequence; + candidate.epoch = resolution.resulting_epoch; + + self.resolutions.insert(resolution.id, resolution.clone()); + self.views.insert(resolution.id, candidate.clone()); + self.selected = candidate; + self.current_head = resolution.id; + self.heads.clear(); + self.heads.insert(resolution.id); + self.forked = false; + self.fork_common = None; + Ok(ApplyDisposition::Applied) + } + + /// Returns a stable snapshot without exposing transition internals. + pub fn snapshot(&self) -> AccountModelSnapshot { + AccountModelSnapshot { + account_id: self.account_id, + sequence: self.selected.sequence, + epoch: self.selected.epoch, + heads: self.heads.iter().copied().collect(), + forked: self.forked, + active_controllers: self.selected.controllers.values().cloned().collect(), + revoked_controllers: self.selected.revoked_controllers.iter().copied().collect(), + devices: self.selected.devices.clone(), + policy: self.selected.policy, + migration: self.selected.migration, + group_key_generation: self.selected.group_key_generation, + group_key_recipients: self.selected.group_key_recipients.iter().copied().collect(), + } + } + + /// Return normalized current head labels and their exact predecessor label sets. + pub fn canonical_head_predecessors(&self) -> BTreeMap> { + self.heads + .iter() + .map(|head| { + let mut predecessors = if let Some(event) = self.events.get(head) { + vec![event.predecessor().get()] + } else if let Some(resolution) = self.resolutions.get(head) { + resolution + .heads + .iter() + .map(|predecessor| predecessor.get()) + .collect() + } else { + Vec::new() + }; + predecessors.sort_unstable(); + (head.get(), predecessors) + }) + .collect() + } +} + +fn validate_next_position(view: &AuthorityView, event: &IdentityEvent) -> Result<(), ModelError> { + let expected_sequence = view + .sequence + .checked_add(1) + .ok_or(ModelError::ArithmeticOverflow("event sequence"))?; + let expected_epoch = event.operation().resulting_epoch(view.epoch)?; + if event.sequence() != expected_sequence || event.resulting_epoch() != expected_epoch { + return Err(ModelError::InvalidSequence); + } + Ok(()) +} + +fn normalize_controllers(controllers: &mut [ModelController]) -> Result<(), ModelError> { + if controllers.is_empty() || controllers.len() > MAX_MODEL_CONTROLLERS { + return Err(ModelError::LimitExceeded("controllers")); + } + controllers.sort_unstable_by_key(ModelController::id); + if controllers + .windows(2) + .any(|pair| pair[0].id() == pair[1].id()) + { + return Err(ModelError::DuplicateController); + } + Ok(()) +} + +fn validate_controller_set( + controllers: &[ModelController], + policy: ModelPolicy, +) -> Result<(), ModelError> { + let available = total_weight(controllers.iter())?; + if available < policy.required_weight() { + return Err(ModelError::UnsatisfiedPolicy { + available, + required: policy.required_weight(), + }); + } + Ok(()) +} + +fn total_weight<'a>( + controllers: impl IntoIterator, +) -> Result { + controllers + .into_iter() + .try_fold(0_u16, |total, controller| { + total + .checked_add(controller.weight()) + .ok_or(ModelError::ArithmeticOverflow("controller weight")) + }) +} + +/// Typed model validation or transition failure. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ModelError { + /// A model identifier that reserves zero received zero. + #[error("{0} identity must be nonzero")] + ZeroIdentifier(&'static str), + /// Controller or policy weights must be nonzero. + #[error("controller and policy weights must be nonzero")] + ZeroWeight, + /// Event zero is reserved and an event cannot name itself as predecessor. + #[error("event identity must be nonzero and distinct from its predecessor")] + ZeroOrSelfEventId, + /// Sequence or operation-specific v1 epoch did not match the named predecessor. + #[error("event sequence or operation-specific v1 epoch is invalid")] + InvalidSequence, + /// A controller appeared twice in a canonical controller set. + #[error("controller set contains a duplicate")] + DuplicateController, + /// A controller approval appeared twice. + #[error("controller approvals contain a duplicate")] + DuplicateApproval, + /// A bounded collection reached its hard limit. + #[error("model {0} limit exceeded")] + LimitExceeded(&'static str), + /// Checked arithmetic could not represent the result. + #[error("model {0} arithmetic overflow")] + ArithmeticOverflow(&'static str), + /// Approval weight was insufficient under the prior policy. + #[error("approval weight {actual} is below required weight {required}")] + InsufficientWeight { actual: u16, required: u16 }, + /// A new policy or controller removal would make authority unsatisfiable. + #[error("available weight {available} is below policy requirement {required}")] + UnsatisfiedPolicy { available: u16, required: u16 }, + /// A revoked controller attempted to approve future state. + #[error("revoked controller {0:?} cannot authorize future state")] + RevokedController(ControllerId), + /// An unknown controller was referenced. + #[error("unknown controller {0:?}")] + UnknownController(ControllerId), + /// A controller ID is already active or tombstoned. + #[error("controller {0:?} is already known")] + ControllerAlreadyKnown(ControllerId), + /// An unknown device was referenced. + #[error("unknown device {0:?}")] + UnknownDevice(DeviceId), + /// A device ID is already active or tombstoned. + #[error("device {0:?} is already known")] + DeviceAlreadyKnown(DeviceId), + /// The event names no retained predecessor. + #[error("unknown predecessor {0:?}")] + UnknownPredecessor(EventId), + /// An event ID was reused for different bytes. + #[error("event identity {0:?} was reused")] + DuplicateEventId(EventId), + /// Ordinary transitions fail closed after conflict detection. + #[error("account is forked and requires explicit resolution")] + ForkedState, + /// A recovery event was submitted through ordinary controller authorization. + #[error("recovery requires the separate recovery authorization path")] + RecoveryAuthorizationRequired, + /// The recovery path was used for a non-recovery operation. + #[error("recovery authorization can apply only a recovery operation")] + RecoveryOperationRequired, + /// Recovery authority is distinct from controller approvals. + #[error("recovery event cannot carry ordinary controller approvals")] + RecoveryHasControllerApprovals, + /// Recovery attempted to reactivate a permanent tombstone. + #[error("recovery cannot reintroduce a revoked controller")] + RecoveryReintroducesRevokedController, + /// Signature-suite migration phases were applied out of order. + #[error("invalid signature-suite migration transition")] + InvalidMigration, + /// Fork resolution did not exactly consume and select from the retained conflict. + #[error("invalid fork resolution")] + InvalidForkResolution, +} diff --git a/krikos-sim/src/identity/replay.rs b/krikos-sim/src/identity/replay.rs new file mode 100644 index 00000000000..fff77cba77a --- /dev/null +++ b/krikos-sim/src/identity/replay.rs @@ -0,0 +1,513 @@ +//! Immutable identity run artifacts and source-bound exact replay. + +use std::path::Path; + +use krikos_runtime::{RootSeed, TraceEvent}; +use serde::{Deserialize, Serialize}; + +use super::{ + IdentityFailedRunRecord, IdentityFailureConfirmation, IdentityFailureReport, + IdentityFailureSignature, IdentityMinimizationResult, IdentityMinimizer, + IdentityRejectedRunRecord, IdentityRejectionEvidence, IdentityRunOutcome, IdentityRunRecord, + IdentityRunReport, IdentityScenario, IdentityScenarioRunner, + corpus::MAX_IDENTITY_MINIMIZATION_ATTEMPTS, verify_identity_failure_artifacts, +}; +use crate::{ + ArtifactStore, ReplayIdentity, RunManifest, bounded_io::read_file, normalized_trace_json, +}; + +const IDENTITY_REJECTION_ARTIFACT_SCHEMA_VERSION: u16 = 1; + +/// Immutable artifact writer for one successful identity simulation. +#[derive(Debug)] +pub struct IdentityArtifactBundle<'a> { + /// Canonical input scenario. + pub scenario: &'a IdentityScenario, + /// Source, configuration, seed, and dependency identity. + pub manifest: &'a RunManifest, + /// Successful report and raw trace produced by the manifest seed. + pub record: &'a IdentityRunRecord, +} + +impl IdentityArtifactBundle<'_> { + /// Writes the manifest, input, semantic report, and both trace representations immutably. + pub fn write(&self, store: &ArtifactStore) -> Result<(), IdentityReplayError> { + self.validate_binding()?; + store + .write_manifest("manifest.json", self.manifest) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_atomic("scenario.json", &self.scenario.to_canonical_json()?) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_atomic( + "identity-report.json", + &canonical_report(&self.record.report)?, + ) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_raw_trace("trace.raw.jsonl", &self.record.trace) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_trace("trace.jsonl", &self.record.trace) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + Ok(()) + } + + fn validate_binding(&self) -> Result<(), IdentityReplayError> { + self.manifest + .validate() + .map_err(|error| IdentityReplayError::Manifest(error.to_string()))?; + if self.manifest.root_seed != encode_seed(self.record.root_seed) { + return Err(IdentityReplayError::SeedMismatch); + } + if self.manifest.scenario_id != self.scenario.id() + || self.manifest.scenario_hash != scenario_digest(self.scenario)? + { + return Err(IdentityReplayError::ScenarioMismatch); + } + Ok(()) + } +} + +/// Versioned terminal evidence for one correct fail-closed model rejection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityRejectionReport { + /// Artifact schema version for expected-rejection evidence. + pub schema_version: u16, + /// Hex-encoded behavioral root seed used by the rejected run. + pub root_seed: String, + /// Exact declared rejection that matched the model terminal. + pub evidence: IdentityRejectionEvidence, + /// Deterministic post-rejection state, scheduler, task, and invariant evidence. + pub report: IdentityRunReport, +} + +/// Immutable artifact writer for one expected identity model rejection. +#[derive(Debug)] +pub struct IdentityRejectionArtifactBundle<'a> { + /// Canonical input scenario. + pub scenario: &'a IdentityScenario, + /// Source, configuration, seed, and dependency identity. + pub manifest: &'a RunManifest, + /// Expected-rejection report and raw trace produced by the manifest seed. + pub record: &'a IdentityRejectedRunRecord, +} + +impl IdentityRejectionArtifactBundle<'_> { + /// Writes an explicitly classified, exactly replayable non-product terminal. + pub fn write(&self, store: &ArtifactStore) -> Result<(), IdentityReplayError> { + self.validate_binding()?; + let rejection = IdentityRejectionReport { + schema_version: IDENTITY_REJECTION_ARTIFACT_SCHEMA_VERSION, + root_seed: encode_seed(self.record.root_seed), + evidence: self.record.evidence.clone(), + report: self.record.report.clone(), + }; + store + .write_manifest("manifest.json", self.manifest) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_atomic("scenario.json", &self.scenario.to_canonical_json()?) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_atomic( + "identity-rejection-report.json", + &canonical_value(&rejection)?, + ) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_raw_trace("trace.raw.jsonl", &self.record.trace) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + store + .write_trace("trace.jsonl", &self.record.trace) + .map_err(|error| IdentityReplayError::Artifact(error.to_string()))?; + Ok(()) + } + + fn validate_binding(&self) -> Result<(), IdentityReplayError> { + self.manifest + .validate() + .map_err(|error| IdentityReplayError::Manifest(error.to_string()))?; + if self.manifest.root_seed != encode_seed(self.record.root_seed) { + return Err(IdentityReplayError::SeedMismatch); + } + if self.manifest.scenario_id != self.scenario.id() + || self.manifest.scenario_hash != scenario_digest(self.scenario)? + || self.record.report.scenario_id != self.scenario.id() + { + return Err(IdentityReplayError::ScenarioMismatch); + } + Ok(()) + } +} + +/// Re-executes a recorded identity run and requires report and raw trace byte equality. +pub fn replay_identity_artifacts( + root: &Path, + current: &ReplayIdentity, +) -> Result { + let manifest = RunManifest::from_json(&read_file(root.join("manifest.json"))?) + .map_err(|error| IdentityReplayError::Manifest(error.to_string()))?; + manifest + .check_compatible(current) + .map_err(|error| IdentityReplayError::Compatibility(error.to_string()))?; + let scenario = IdentityScenario::from_json(&read_file(root.join("scenario.json"))?)?; + if manifest.scenario_id != scenario.id() + || manifest.scenario_hash != scenario_digest(&scenario)? + { + return Err(IdentityReplayError::ScenarioMismatch); + } + let seed = decode_seed(&manifest.root_seed)?; + let actual = match IdentityScenarioRunner::run_detailed(&scenario, RootSeed::new(seed))? { + IdentityRunOutcome::Success(record) => record, + IdentityRunOutcome::ExpectedRejection(_) => { + return Err(IdentityReplayError::UnexpectedExpectedRejection); + } + IdentityRunOutcome::Failed(_) => { + return Err(IdentityReplayError::UnexpectedProductFailure); + } + }; + let expected_report = read_file(root.join("identity-report.json"))?; + let actual_report = canonical_report(&actual.report)?; + if expected_report != actual_report { + return Err(IdentityReplayError::ReportDivergence); + } + let expected_raw = read_file(root.join("trace.raw.jsonl"))?; + if expected_raw != raw_trace_bytes(&actual.trace)? { + return Err(IdentityReplayError::RawTraceDivergence); + } + let expected_normalized = read_file(root.join("trace.jsonl"))?; + if expected_normalized != normalized_trace_bytes(&actual.trace)? { + return Err(IdentityReplayError::NormalizedTraceDivergence); + } + Ok(actual) +} + +/// Re-executes a correct model rejection and requires its explicit terminal, report, and traces. +pub fn replay_identity_rejection_artifacts( + root: &Path, + current: &ReplayIdentity, +) -> Result { + let manifest = RunManifest::from_json(&read_file(root.join("manifest.json"))?) + .map_err(|error| IdentityReplayError::Manifest(error.to_string()))?; + manifest + .check_compatible(current) + .map_err(|error| IdentityReplayError::Compatibility(error.to_string()))?; + let scenario = IdentityScenario::from_json(&read_file(root.join("scenario.json"))?)?; + if manifest.scenario_id != scenario.id() + || manifest.scenario_hash != scenario_digest(&scenario)? + { + return Err(IdentityReplayError::ScenarioMismatch); + } + let seed = decode_seed(&manifest.root_seed)?; + let actual = match IdentityScenarioRunner::run_detailed(&scenario, RootSeed::new(seed))? { + IdentityRunOutcome::ExpectedRejection(record) => record, + IdentityRunOutcome::Success(_) => { + return Err(IdentityReplayError::ExpectedRejectionDisappeared); + } + IdentityRunOutcome::Failed(_) => { + return Err(IdentityReplayError::ExpectedRejectionBecameFailure); + } + }; + let persisted_report = read_file(root.join("identity-rejection-report.json"))?; + let _: IdentityRejectionReport = serde_json::from_slice(&persisted_report) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?; + let reconstructed_report = IdentityRejectionReport { + schema_version: IDENTITY_REJECTION_ARTIFACT_SCHEMA_VERSION, + root_seed: encode_seed(actual.root_seed), + evidence: actual.evidence.clone(), + report: actual.report.clone(), + }; + if persisted_report != canonical_value(&reconstructed_report)? { + return Err(IdentityReplayError::RejectionReportDivergence); + } + if read_file(root.join("trace.raw.jsonl"))? != raw_trace_bytes(&actual.trace)? { + return Err(IdentityReplayError::RawTraceDivergence); + } + if read_file(root.join("trace.jsonl"))? != normalized_trace_bytes(&actual.trace)? { + return Err(IdentityReplayError::NormalizedTraceDivergence); + } + Ok(actual) +} + +/// Re-executes one committed minimized failure and requires exact terminal evidence and traces. +pub fn replay_identity_failure_artifacts( + root: &Path, + current: &ReplayIdentity, +) -> Result { + verify_identity_failure_artifacts(root)?; + let manifest = RunManifest::from_json(&read_file(root.join("manifest.json"))?) + .map_err(|error| IdentityReplayError::Manifest(error.to_string()))?; + manifest + .check_compatible(current) + .map_err(|error| IdentityReplayError::Compatibility(error.to_string()))?; + let minimized_scenario = IdentityScenario::from_json(&read_file(root.join("scenario.json"))?)?; + let recorded_minimized = + IdentityScenario::from_json(&read_file(root.join("failure-minimized.json"))?)?; + let original_scenario = + IdentityScenario::from_json(&read_file(root.join("failure-original.json"))?)?; + if manifest.scenario_id != minimized_scenario.id() + || manifest.scenario_hash != scenario_digest(&minimized_scenario)? + || recorded_minimized != minimized_scenario + || original_scenario.id() != minimized_scenario.id() + { + return Err(IdentityReplayError::ScenarioMismatch); + } + let expected_signature = + IdentityFailureSignature::from_json(&read_file(root.join("failure-signature.json"))?)?; + let confirmation: IdentityFailureConfirmation = + serde_json::from_slice(&read_file(root.join("failure-confirmation.json"))?) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?; + let minimization: IdentityMinimizationResult = + serde_json::from_slice(&read_file(root.join("failure-minimization.json"))?) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?; + let seed = decode_seed(&manifest.root_seed)?; + let original = replay_failed_scenario(&original_scenario, seed)?; + let original_confirmation = replay_failed_scenario(&original_scenario, seed)?; + let minimized = replay_failed_scenario(&minimized_scenario, seed)?; + let minimized_confirmation = replay_failed_scenario(&minimized_scenario, seed)?; + if original != original_confirmation || minimized != minimized_confirmation { + return Err(IdentityReplayError::FailureConfirmationDivergence); + } + if original.signature()? != expected_signature || minimized.signature()? != expected_signature { + return Err(IdentityReplayError::FailureSignatureDivergence); + } + let expected_original_report: IdentityFailureReport = serde_json::from_slice(&read_file( + root.join("identity-failure-original-report.json"), + )?) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?; + if expected_original_report.root_seed != manifest.root_seed + || expected_original_report.evidence != original.evidence + || expected_original_report.report != original.report + { + return Err(IdentityReplayError::FailureReportDivergence); + } + let expected_minimized_report: IdentityFailureReport = + serde_json::from_slice(&read_file(root.join("identity-failure-report.json"))?) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?; + if expected_minimized_report.root_seed != manifest.root_seed + || expected_minimized_report.evidence != minimized.evidence + || expected_minimized_report.report != minimized.report + { + return Err(IdentityReplayError::FailureReportDivergence); + } + let original_raw = raw_trace_bytes(&original.trace)?; + if read_file(root.join("trace-original.raw.jsonl"))? != original_raw { + return Err(IdentityReplayError::RawTraceDivergence); + } + let original_normalized = normalized_trace_bytes(&original.trace)?; + if read_file(root.join("trace-original.jsonl"))? != original_normalized { + return Err(IdentityReplayError::NormalizedTraceDivergence); + } + let minimized_raw = raw_trace_bytes(&minimized.trace)?; + if read_file(root.join("trace.raw.jsonl"))? != minimized_raw { + return Err(IdentityReplayError::RawTraceDivergence); + } + let minimized_normalized = normalized_trace_bytes(&minimized.trace)?; + if read_file(root.join("trace.jsonl"))? != minimized_normalized { + return Err(IdentityReplayError::NormalizedTraceDivergence); + } + if confirmation.signature != expected_signature + || confirmation.root_seed != manifest.root_seed + || confirmation.original_scenario_digest != scenario_digest(&original_scenario)? + || confirmation.minimized_scenario_digest != scenario_digest(&minimized_scenario)? + || confirmation.original_report_digest + != blake3_digest(&canonical_report(&original.report)?) + || confirmation.original_raw_trace_digest != blake3_digest(&original_raw) + || confirmation.original_normalized_trace_digest != blake3_digest(&original_normalized) + || confirmation.minimized_report_digest + != blake3_digest(&canonical_report(&minimized.report)?) + || confirmation.minimized_raw_trace_digest != blake3_digest(&minimized_raw) + || confirmation.minimized_normalized_trace_digest != blake3_digest(&minimized_normalized) + || confirmation.original_confirmations != 2 + || confirmation.minimized_confirmations != 2 + || minimization.signature != expected_signature + || minimization.scenario != minimized_scenario + { + return Err(IdentityReplayError::FailureConfirmationDivergence); + } + let replay_budget = if minimization.exhausted { + u64::try_from(minimization.attempts.len()) + .map_err(|_| IdentityReplayError::FailureConfirmationDivergence)? + } else { + MAX_IDENTITY_MINIMIZATION_ATTEMPTS + }; + if replay_budget == 0 { + return Err(IdentityReplayError::FailureConfirmationDivergence); + } + let mut evaluator = |candidate: &IdentityScenario| match IdentityScenarioRunner::run_detailed( + candidate, + RootSeed::new(seed), + ) + .map_err(|error| error.to_string())? + { + IdentityRunOutcome::Success(_) => Ok(None), + IdentityRunOutcome::ExpectedRejection(_) => Ok(None), + IdentityRunOutcome::Failed(failure) => failure + .signature() + .map(Some) + .map_err(|error| error.to_string()), + }; + let reconstructed = IdentityMinimizer::new(replay_budget)?.minimize( + original_scenario, + expected_signature, + &mut evaluator, + )?; + if reconstructed != minimization { + return Err(IdentityReplayError::FailureConfirmationDivergence); + } + Ok(minimized) +} + +fn replay_failed_scenario( + scenario: &IdentityScenario, + seed: [u8; 32], +) -> Result { + match IdentityScenarioRunner::run_detailed(scenario, RootSeed::new(seed))? { + IdentityRunOutcome::Success(_) => Err(IdentityReplayError::FailureDisappeared), + IdentityRunOutcome::ExpectedRejection(_) => { + Err(IdentityReplayError::FailureBecameExpectedRejection) + } + IdentityRunOutcome::Failed(failure) => Ok(failure), + } +} + +fn scenario_digest(scenario: &IdentityScenario) -> Result { + Ok(blake3::hash(&scenario.to_canonical_json()?) + .to_hex() + .to_string()) +} + +fn canonical_report(report: &IdentityRunReport) -> Result, IdentityReplayError> { + canonical_value(report) +} + +fn canonical_value(value: &impl Serialize) -> Result, IdentityReplayError> { + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn raw_trace_bytes(trace: &[TraceEvent]) -> Result, IdentityReplayError> { + let mut bytes = Vec::new(); + for event in trace { + bytes.extend( + serde_json::to_vec(event) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?, + ); + bytes.push(b'\n'); + } + Ok(bytes) +} + +fn normalized_trace_bytes(trace: &[TraceEvent]) -> Result, IdentityReplayError> { + let mut bytes = Vec::new(); + for event in trace { + bytes.extend( + normalized_trace_json(event) + .map_err(|error| IdentityReplayError::Encoding(error.to_string()))?, + ); + bytes.push(b'\n'); + } + Ok(bytes) +} + +fn blake3_digest(bytes: &[u8]) -> String { + blake3::hash(bytes).to_hex().to_string() +} + +fn encode_seed(seed: [u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(64); + for byte in seed { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +fn decode_seed(value: &str) -> Result<[u8; 32], IdentityReplayError> { + if value.len() != 64 { + return Err(IdentityReplayError::InvalidSeed); + } + let mut seed = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = decode_nibble(pair[0]).ok_or(IdentityReplayError::InvalidSeed)?; + let low = decode_nibble(pair[1]).ok_or(IdentityReplayError::InvalidSeed)?; + seed[index] = high + .checked_mul(16) + .and_then(|value| value.checked_add(low)) + .ok_or(IdentityReplayError::InvalidSeed)?; + } + Ok(seed) +} + +const fn decode_nibble(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + } +} + +/// Artifact binding or exact replay failure. +#[derive(Debug, thiserror::Error)] +pub enum IdentityReplayError { + #[error("identity artifact I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("identity artifact write failed: {0}")] + Artifact(String), + #[error("identity manifest is invalid: {0}")] + Manifest(String), + #[error("identity replay compatibility failed: {0}")] + Compatibility(String), + #[error("identity scenario binding does not match the manifest")] + ScenarioMismatch, + #[error("identity root seed does not match the manifest")] + SeedMismatch, + #[error("identity root seed is not lowercase 32-byte hexadecimal")] + InvalidSeed, + #[error("identity replay report diverged")] + ReportDivergence, + #[error("identity replay raw trace diverged")] + RawTraceDivergence, + #[error("identity replay normalized trace diverged")] + NormalizedTraceDivergence, + /// A success artifact replay reached a correctly declared model rejection. + #[error("identity success replay reached an expected model rejection")] + UnexpectedExpectedRejection, + /// A success artifact replay reached a product failure. + #[error("identity success replay reached a product failure")] + UnexpectedProductFailure, + /// An expected-rejection artifact replay completed successfully. + #[error("identity replay expected a model rejection, but the scenario succeeded")] + ExpectedRejectionDisappeared, + /// An expected-rejection artifact replay reached a product failure. + #[error( + "identity replay expected a model rejection, but the scenario reached a product failure" + )] + ExpectedRejectionBecameFailure, + /// Expected-rejection evidence no longer matches its persisted report. + #[error("identity replay expected-rejection report diverged")] + RejectionReportDivergence, + #[error("identity replay expected a failure, but the minimized scenario succeeded")] + FailureDisappeared, + /// A product-failure artifact replay became a correctly declared model rejection. + #[error("identity replay expected a product failure, but reached an expected model rejection")] + FailureBecameExpectedRejection, + #[error("identity replay failure signature diverged")] + FailureSignatureDivergence, + #[error("identity replay failed terminal report diverged")] + FailureReportDivergence, + #[error("identity replay failure confirmation or minimization evidence diverged")] + FailureConfirmationDivergence, + #[error("identity replay encoding failed: {0}")] + Encoding(String), + #[error(transparent)] + Scenario(#[from] super::IdentityScenarioError), + #[error(transparent)] + Corpus(#[from] super::IdentityCorpusError), +} diff --git a/krikos-sim/src/identity/scenario.rs b/krikos-sim/src/identity/scenario.rs new file mode 100644 index 00000000000..ef1aafa77ce --- /dev/null +++ b/krikos-sim/src/identity/scenario.rs @@ -0,0 +1,2428 @@ +//! Kernel-owned deterministic identity scenarios and identity invariant accounting. + +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, Mutex}, + time::Duration, +}; + +use krikos_runtime::{ClockSleep, RootSeed, TaskKind, TraceContext, TraceEvent, TraceEventKind}; +use serde::{Deserialize, Serialize}; + +use super::{ + AccountControlModel, AccountModelSnapshot, ControllerId, DeviceId, DeviceLifecycle, EventId, + ForkResolution, IdentityEvent, IdentityOperation, ModelController, ModelError, ModelPolicy, + RecoveryPlan, + model::{MAX_MODEL_CONTROLLERS, MAX_MODEL_DEVICES, MAX_MODEL_FORK_HEADS}, +}; +use crate::{ + Kernel, KernelConfig, KernelResourceLimits, KernelSchedulerSnapshot, KernelTaskSnapshot, + Quiescence, TraceBuffer, +}; + +/// Strict identity-scenario schema version. +pub const IDENTITY_SCENARIO_SCHEMA_VERSION: u16 = 1; +/// Hard encoded-byte bound for one identity scenario. +pub const MAX_IDENTITY_SCENARIO_BYTES: usize = 4 * 1024 * 1024; +/// Hard action bound for one identity scenario. +pub const MAX_IDENTITY_ACTIONS: usize = 256; +const MAX_IDENTITY_TEXT_BYTES: usize = 128; +const MAX_IDENTITY_VIRTUAL_NANOS: u64 = 60_000_000_000; +const MAX_IDENTITY_DELIVERIES: usize = 256; +const MAX_IDENTITY_REPLICAS: usize = 4; +const MAX_IDENTITY_PROVIDER_OBSERVATIONS: usize = 16; + +/// One weighted replacement controller in a recovery action. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RecoveryController { + /// Run-local controller identity. + pub controller: u16, + /// Nonzero authority weight. + pub weight: u16, +} + +/// Delivery behaviors exercised without granting the transport authority. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityDeliveryFault { + /// Delivery is deferred on virtual time. + Delay, + /// Concurrent deliveries may arrive in seeded scheduler order. + Reorder, + /// A queued delivery is omitted. + Loss, + /// An already delivered event is replayed. + Duplicate, +} + +/// Operations permitted on sibling fork proposals. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ForkScenarioOperation { + /// Add one controller on this branch. + AddController { controller: u16, weight: u16 }, + /// Authorize one device on this branch. + AuthorizeDevice { device: u16 }, + /// Change the threshold on this branch. + ChangePolicy { required_weight: u16 }, +} + +/// Explicit migration phase used by high-level scenarios. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPhase { + /// Stage the replacement suite. + Begin, + /// Enter dual-signature authority. + Activate, + /// Retire the previous suite. + Complete, +} + +/// Stable externally triggerable model-rejection discriminants permitted in scenario expectations. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExpectedModelRejection { + /// Prior-policy approvals carry less weight than the retained threshold. + InsufficientWeight, + /// A permanently revoked controller attempted to approve a transition. + RevokedController, + /// An approval or operation target names no active controller. + UnknownController, + /// A controller-add operation reuses an active or tombstoned identity. + ControllerAlreadyKnown, + /// A policy or removal would leave insufficient active authority. + UnsatisfiedPolicy, + /// A controller-add operation reaches the bounded controller capacity. + ControllerLimitExceeded, + /// A device-authorization operation reaches the bounded device capacity. + DeviceLimitExceeded, + /// An account transition reaches the bounded retained-event capacity. + EventLimitExceeded, + /// A device operation or resolution names no known device. + UnknownDevice, + /// A device operation reuses an active or revoked identity. + DeviceAlreadyKnown, + /// Recovery attempts to reactivate a permanently revoked controller. + RecoveryReintroducesRevokedController, + /// A signature-suite migration phase is applied out of order. + InvalidMigration, + /// A resolution does not exactly consume and select from the retained fork. + InvalidForkResolution, +} + +impl ExpectedModelRejection { + fn from_model_error(error: &ModelError) -> Option { + match error { + ModelError::InsufficientWeight { .. } => Some(Self::InsufficientWeight), + ModelError::RevokedController(_) => Some(Self::RevokedController), + ModelError::UnknownController(_) => Some(Self::UnknownController), + ModelError::ControllerAlreadyKnown(_) => Some(Self::ControllerAlreadyKnown), + ModelError::UnsatisfiedPolicy { .. } => Some(Self::UnsatisfiedPolicy), + ModelError::LimitExceeded("controllers") => Some(Self::ControllerLimitExceeded), + ModelError::LimitExceeded("devices") => Some(Self::DeviceLimitExceeded), + ModelError::LimitExceeded("events") => Some(Self::EventLimitExceeded), + ModelError::UnknownDevice(_) => Some(Self::UnknownDevice), + ModelError::DeviceAlreadyKnown(_) => Some(Self::DeviceAlreadyKnown), + ModelError::RecoveryReintroducesRevokedController => { + Some(Self::RecoveryReintroducesRevokedController) + } + ModelError::InvalidMigration => Some(Self::InvalidMigration), + ModelError::InvalidForkResolution => Some(Self::InvalidForkResolution), + ModelError::ZeroIdentifier(_) + | ModelError::ZeroWeight + | ModelError::ZeroOrSelfEventId + | ModelError::InvalidSequence + | ModelError::DuplicateController + | ModelError::DuplicateApproval + | ModelError::ArithmeticOverflow(_) + | ModelError::LimitExceeded(_) + | ModelError::DuplicateEventId(_) + | ModelError::UnknownPredecessor(_) + | ModelError::ForkedState + | ModelError::RecoveryAuthorizationRequired + | ModelError::RecoveryOperationRequired + | ModelError::RecoveryHasControllerApprovals => None, + } + } + + /// Stable spelling committed by canonical scenarios and replay evidence. + pub const fn as_str(self) -> &'static str { + match self { + Self::InsufficientWeight => "insufficient_weight", + Self::RevokedController => "revoked_controller", + Self::UnknownController => "unknown_controller", + Self::ControllerAlreadyKnown => "controller_already_known", + Self::UnsatisfiedPolicy => "unsatisfied_policy", + Self::ControllerLimitExceeded => "controller_limit_exceeded", + Self::DeviceLimitExceeded => "device_limit_exceeded", + Self::EventLimitExceeded => "event_limit_exceeded", + Self::UnknownDevice => "unknown_device", + Self::DeviceAlreadyKnown => "device_already_known", + Self::RecoveryReintroducesRevokedController => { + "recovery_reintroduces_revoked_controller" + } + Self::InvalidMigration => "invalid_migration", + Self::InvalidForkResolution => "invalid_fork_resolution", + } + } +} + +/// Per-action terminal contract. Success remains the backwards-compatible default. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "terminal", rename_all = "snake_case", deny_unknown_fields)] +pub enum IdentityActionExpectation { + /// The action must complete successfully; this is omitted from canonical JSON. + #[default] + Success, + /// The action must fail closed with exactly the declared model discriminant. + ModelRejection { + /// Exact externally triggerable model rejection required by this action. + rejection: ExpectedModelRejection, + }, +} + +impl IdentityActionExpectation { + fn is_success(&self) -> bool { + matches!(self, Self::Success) + } + + const fn expected_model_rejection(self) -> Option { + match self { + Self::Success => None, + Self::ModelRejection { rejection } => Some(rejection), + } + } +} + +/// Closed action vocabulary for identity hardening scenarios. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum IdentityScenarioAction { + /// Partition identity replicas. + Partition, + /// Heal all modeled network partitions. + Heal, + /// Exercise one delivery impairment. + DeliveryFault { fault: IdentityDeliveryFault }, + /// Add an ordinary account controller. + AddController { + controller: u16, + weight: u16, + approvals: Vec, + }, + /// Change the control threshold. + ChangePolicy { + required_weight: u16, + approvals: Vec, + }, + /// Authorize an independently keyed device. + AuthorizeDevice { device: u16, approvals: Vec }, + /// Permanently revoke one device. + RevokeDevice { device: u16, approvals: Vec }, + /// Permanently revoke one controller. + RevokeController { + controller: u16, + approvals: Vec, + }, + /// Submit one sibling proposal; co-timed siblings run in seeded scheduler order. + ForkProposal { + fork: String, + branch: String, + approvals: Vec, + operation: ForkScenarioOperation, + }, + /// Explicitly choose one retained fork branch. + ResolveFork { + fork: String, + selected_branch: String, + approvals: Vec, + revoked_controllers: Vec, + revoked_devices: Vec, + }, + /// Crash one non-authoritative replica. + Crash { replica: u16 }, + /// Reopen one replica, optionally without its cached projection. + Reopen { replica: u16, storage_loss: bool }, + /// Make configured provider evidence unavailable. + ProviderOutage, + /// Restore provider availability and a consistent view. + ProviderRestore, + /// Present inconsistent provider views without mutating authority. + ProviderEquivocation, + /// Probe a freshness-sensitive action, which must fail closed when evidence is unsafe. + SensitiveProbe, + /// Replace account authority through the distinct recovery path. + Recover { + controllers: Vec, + required_weight: u16, + }, + /// Advance one signature-suite migration phase. + Migration { + phase: MigrationPhase, + approvals: Vec, + }, + /// Rotate group keys to exactly active devices. + RotateGroupKey { approvals: Vec }, + /// Durably publish one pending revocation proof. + PublishRevocation { subject: String }, + /// Validate against and retain an exact offline sequence/epoch basis. + OfflineValidate, + /// Create a social edge with no account-control authority. + SocialRelationship, + /// Simulator-only fault used to prove one identity-invariant oracle and failure workflow. + InvariantFault { mutation: IdentityInvariantMutation }, +} + +/// One scheduled action with a stable identity and absolute virtual deadline. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityAction { + id: String, + at_nanos: u64, + action: IdentityScenarioAction, + #[serde(default, skip_serializing_if = "IdentityActionExpectation::is_success")] + expectation: IdentityActionExpectation, +} + +impl IdentityAction { + /// Creates one bounded action. + pub fn new( + id: impl Into, + at_nanos: u64, + action: IdentityScenarioAction, + ) -> Result { + let action = Self { + id: id.into(), + at_nanos, + action, + expectation: IdentityActionExpectation::Success, + }; + action.validate()?; + Ok(action) + } + + /// Stable action identity used by minimization and diagnostics. + pub fn id(&self) -> &str { + &self.id + } + + /// Declares one exact model rejection as the action's expected fail-closed terminal. + pub fn expect_model_rejection( + mut self, + rejection: ExpectedModelRejection, + ) -> Result { + self.expectation = IdentityActionExpectation::ModelRejection { rejection }; + self.validate()?; + Ok(self) + } + + fn validate(&self) -> Result<(), IdentityScenarioError> { + validate_text(&self.id)?; + if self.at_nanos > MAX_IDENTITY_VIRTUAL_NANOS { + return Err(IdentityScenarioError::InvalidVirtualTime(self.at_nanos)); + } + for text in action_text_fields(&self.action) { + validate_text(text)?; + } + validate_action_semantics(&self.id, &self.action)?; + validate_action_expectation(&self.id, &self.action, self.expectation)?; + Ok(()) + } +} + +/// Strict, canonical deterministic identity scenario. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityScenario { + schema_version: u16, + id: String, + actions: Vec, +} + +impl IdentityScenario { + /// Creates and validates a scenario. + pub fn new( + id: impl Into, + actions: Vec, + ) -> Result { + let scenario = Self { + schema_version: IDENTITY_SCENARIO_SCHEMA_VERSION, + id: id.into(), + actions, + }; + scenario.validate()?; + Ok(scenario) + } + + /// Parses strict JSON and validates all bounds and identities. + pub fn from_json(bytes: &[u8]) -> Result { + if bytes.len() > MAX_IDENTITY_SCENARIO_BYTES { + return Err(IdentityScenarioError::InputTooLarge { + actual: bytes.len(), + maximum: MAX_IDENTITY_SCENARIO_BYTES, + }); + } + let scenario: Self = serde_json::from_slice(bytes) + .map_err(|error| IdentityScenarioError::Encoding(error.to_string()))?; + scenario.validate()?; + Ok(scenario) + } + + /// Encodes canonical pretty JSON with one final newline. + pub fn to_canonical_json(&self) -> Result, IdentityScenarioError> { + self.validate()?; + let mut bytes = serde_json::to_vec_pretty(self) + .map_err(|error| IdentityScenarioError::Encoding(error.to_string()))?; + bytes.push(b'\n'); + if bytes.len() > MAX_IDENTITY_SCENARIO_BYTES { + return Err(IdentityScenarioError::InputTooLarge { + actual: bytes.len(), + maximum: MAX_IDENTITY_SCENARIO_BYTES, + }); + } + Ok(bytes) + } + + /// Stable scenario identity. + pub fn id(&self) -> &str { + &self.id + } + + /// Actions in declaration order. + pub fn actions(&self) -> &[IdentityAction] { + &self.actions + } + + /// Validates schema, bounds, action IDs, and semantic references. + pub fn validate(&self) -> Result<(), IdentityScenarioError> { + if self.schema_version != IDENTITY_SCENARIO_SCHEMA_VERSION { + return Err(IdentityScenarioError::UnsupportedSchema( + self.schema_version, + )); + } + validate_text(&self.id)?; + if self.actions.is_empty() || self.actions.len() > MAX_IDENTITY_ACTIONS { + return Err(IdentityScenarioError::InvalidActionCount( + self.actions.len(), + )); + } + let mut ids = BTreeSet::new(); + let mut forks = BTreeMap::<&str, BTreeSet<&str>>::new(); + let mut replicas = BTreeSet::new(); + let mut provider_observations = 0_usize; + for action in &self.actions { + action.validate()?; + if !ids.insert(action.id.as_str()) { + return Err(IdentityScenarioError::DuplicateAction(action.id.clone())); + } + match &action.action { + IdentityScenarioAction::ForkProposal { fork, branch, .. } => { + let branches = forks.entry(fork).or_default(); + if !branches.insert(branch) || branches.len() > MAX_MODEL_FORK_HEADS { + return Err(invalid_action( + &action.id, + "fork branches must be unique and within the retained-head bound", + )); + } + } + IdentityScenarioAction::Crash { replica } + | IdentityScenarioAction::Reopen { replica, .. } => { + replicas.insert(*replica); + if replicas.len() > MAX_IDENTITY_REPLICAS { + return Err(invalid_action( + &action.id, + "scenario exceeds the replica bound", + )); + } + } + IdentityScenarioAction::ProviderOutage + | IdentityScenarioAction::ProviderRestore + | IdentityScenarioAction::ProviderEquivocation => { + provider_observations = provider_observations + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?; + if provider_observations > MAX_IDENTITY_PROVIDER_OBSERVATIONS { + return Err(invalid_action( + &action.id, + "scenario exceeds the provider-observation bound", + )); + } + } + _ => {} + } + } + for action in &self.actions { + if let IdentityScenarioAction::ResolveFork { + fork, + selected_branch, + .. + } = &action.action + && forks + .get(fork.as_str()) + .is_none_or(|branches| !branches.contains(selected_branch.as_str())) + { + return Err(invalid_action( + &action.id, + "fork resolution must select a declared branch", + )); + } + } + Ok(()) + } +} + +/// Required Lane A behavior coverage, derived solely from executed actions. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityCoverage { + pub partition: bool, + pub heal: bool, + pub delay: bool, + pub reorder: bool, + pub loss: bool, + pub duplicate: bool, + pub fork: bool, + pub fork_resolution: bool, + pub crash: bool, + pub reopen: bool, + pub storage_loss: bool, + pub provider_outage: bool, + pub provider_equivocation: bool, + pub recovery: bool, + pub controller_revocation: bool, + pub device_revocation: bool, + pub migration_begin: bool, + pub migration_activate: bool, + pub migration_complete: bool, + pub group_key_rotation: bool, +} + +impl IdentityCoverage { + /// Returns whether every required deterministic hardening behavior was exercised. + pub const fn covers_lane_a(self) -> bool { + self.partition + && self.heal + && self.delay + && self.reorder + && self.loss + && self.duplicate + && self.fork + && self.fork_resolution + && self.crash + && self.reopen + && self.storage_loss + && self.provider_outage + && self.provider_equivocation + && self.recovery + && self.controller_revocation + && self.device_revocation + && self.migration_begin + && self.migration_activate + && self.migration_complete + && self.group_key_rotation + } + + /// Derives declared coverage without executing the scenario. + pub fn from_scenario(scenario: &IdentityScenario) -> Self { + let mut coverage = Self::default(); + for action in scenario.actions() { + coverage.observe(&action.action); + } + coverage + } + + /// Union used by strict corpus coverage validation. + pub fn include(&mut self, other: Self) { + self.partition |= other.partition; + self.heal |= other.heal; + self.delay |= other.delay; + self.reorder |= other.reorder; + self.loss |= other.loss; + self.duplicate |= other.duplicate; + self.fork |= other.fork; + self.fork_resolution |= other.fork_resolution; + self.crash |= other.crash; + self.reopen |= other.reopen; + self.storage_loss |= other.storage_loss; + self.provider_outage |= other.provider_outage; + self.provider_equivocation |= other.provider_equivocation; + self.recovery |= other.recovery; + self.controller_revocation |= other.controller_revocation; + self.device_revocation |= other.device_revocation; + self.migration_begin |= other.migration_begin; + self.migration_activate |= other.migration_activate; + self.migration_complete |= other.migration_complete; + self.group_key_rotation |= other.group_key_rotation; + } +} + +/// Per-invariant evaluation counters emitted after every action. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityInvariantCounters { + pub account_is_not_device: u64, + pub no_ordinary_private_key_replication: u64, + pub device_independently_revocable: u64, + pub prior_policy_authorization: u64, + pub stable_account_identity: u64, + pub provider_cannot_create_state: u64, + pub social_no_implicit_authority: u64, + pub published_revocation_discoverability: u64, + pub offline_validation_has_basis: u64, + pub sensitive_actions_fail_closed: u64, + pub revoked_device_excluded_from_group_keys: u64, + pub conflicts_detected_not_merged: u64, +} + +impl IdentityInvariantCounters { + /// Every identity invariant must be evaluated once per executed action. + pub fn all_checked_at_each_step(self, steps: usize) -> bool { + let Ok(expected) = u64::try_from(steps) else { + return false; + }; + [ + self.account_is_not_device, + self.no_ordinary_private_key_replication, + self.device_independently_revocable, + self.prior_policy_authorization, + self.stable_account_identity, + self.provider_cannot_create_state, + self.social_no_implicit_authority, + self.published_revocation_discoverability, + self.offline_validation_has_basis, + self.sensitive_actions_fail_closed, + self.revoked_device_excluded_from_group_keys, + self.conflicts_detected_not_merged, + ] + .into_iter() + .all(|count| count == expected && count > 0) + } +} + +/// Deliberate observation mutation used to prove each identity-invariant oracle is live. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityInvariantMutation { + AccountIsDevice, + OrdinaryPrivateKeyReplication, + DeviceNotIndependentlyRevocable, + PriorPolicyBypass, + AccountIdentityChanged, + ProviderCreatedState, + SocialRelationshipCreatedAuthority, + PublishedRevocationUndiscoverable, + OfflineValidationWithoutBasis, + SensitiveActionDidNotFailClosed, + RevokedDeviceReceivedGroupKey, + ConflictSilentlyMerged, +} + +/// One post-action state and invariant observation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityStepReport { + pub action_id: String, + pub outcome: String, + pub state: AccountModelSnapshot, + pub environment: IdentityEnvironmentSnapshot, +} + +/// Simulator-owned delivery evidence for partition and transport-fault actions. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityDeliveryReport { + pub pending: Vec, + pub delivered: Vec, + pub delayed: u64, + pub reordered: u64, + pub dropped: u64, + pub duplicate_deliveries: u64, +} + +/// One non-authoritative replica's deterministic storage/lifecycle observation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityReplicaSnapshot { + pub replica: u16, + pub crashed: bool, + pub has_projection: bool, +} + +/// Simulator-owned transport, provider, and replica facts after one action. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityEnvironmentSnapshot { + pub partitioned: bool, + pub provider_available: bool, + pub provider_consistent: bool, + pub replicas: Vec, + pub delivery: IdentityDeliveryReport, +} + +/// Deterministic terminal report, including scheduler and task ownership evidence. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityRunReport { + pub schema_version: u16, + pub scenario_id: String, + pub steps: Vec, + pub final_state: AccountModelSnapshot, + pub coverage: IdentityCoverage, + pub invariants: IdentityInvariantCounters, + pub delivery: IdentityDeliveryReport, + pub scheduler: KernelSchedulerSnapshot, + pub tasks: Vec, + pub events_executed: u64, + pub virtual_time_nanos: u64, +} + +/// Complete in-memory record used for byte-exact replay and immutable artifacts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityRunRecord { + /// Behavioral root seed required for exact replay. + pub root_seed: [u8; 32], + /// Stable semantic report. + pub report: IdentityRunReport, + /// Raw structured runtime trace. + pub trace: Vec, +} + +/// Stable class for a deterministic product failure observed inside a scenario task. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityFailureClass { + /// An unmarked, mismatched, or internally invalid model transition failed. + Model, + /// A scenario action violated its declared transition contract. + Execution, + /// An identity-invariant postcondition failed. + Invariant, + /// Checked arithmetic exhausted a declared bound. + Arithmetic, + /// The deterministic runtime or trace recorder failed. + Runtime, +} + +impl IdentityFailureClass { + /// Stable spelling committed by failure signatures and regression metadata. + pub const fn as_str(self) -> &'static str { + match self { + Self::Model => "model", + Self::Execution => "execution", + Self::Invariant => "invariant", + Self::Arithmetic => "arithmetic", + Self::Runtime => "runtime", + } + } +} + +/// Stable non-product terminal class for a correctly rejected protocol transition. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityRejectionClass { + /// The independent account-control model rejected an invalid transition atomically. + Model, +} + +impl IdentityRejectionClass { + /// Stable spelling committed by expected-rejection replay artifacts. + pub const fn as_str(self) -> &'static str { + match self { + Self::Model => "model", + } + } +} + +/// Bounded, typed evidence for a correct fail-closed protocol rejection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityRejectionEvidence { + /// Stable expected-rejection class. + pub class: IdentityRejectionClass, + /// Exact action-declared model-rejection discriminant that matched. + pub rejection: ExpectedModelRejection, + /// Deterministic model evidence explaining why the transition was rejected. + pub detail: String, +} + +impl IdentityRejectionEvidence { + fn new(rejection: ExpectedModelRejection, error: &IdentityScenarioError) -> Self { + Self { + class: IdentityRejectionClass::Model, + rejection, + detail: error.to_string(), + } + } +} + +/// Bounded, typed evidence used to derive one exact failure identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityFailureEvidence { + /// Stable terminal class. + pub class: IdentityFailureClass, + /// Deterministic error evidence produced by the model or invariant oracle. + pub detail: String, +} + +impl IdentityFailureEvidence { + fn from_error(error: &IdentityScenarioError) -> Self { + let class = match error { + IdentityScenarioError::Model(_) => IdentityFailureClass::Model, + IdentityScenarioError::ExpectedRejection(_) | IdentityScenarioError::Execution(_) => { + IdentityFailureClass::Execution + } + IdentityScenarioError::Invariant(_) => IdentityFailureClass::Invariant, + IdentityScenarioError::ArithmeticOverflow => IdentityFailureClass::Arithmetic, + IdentityScenarioError::Runtime(_) => IdentityFailureClass::Runtime, + IdentityScenarioError::UnsupportedSchema(_) + | IdentityScenarioError::InputTooLarge { .. } + | IdentityScenarioError::InvalidText(_) + | IdentityScenarioError::InvalidActionCount(_) + | IdentityScenarioError::DuplicateAction(_) + | IdentityScenarioError::InvalidVirtualTime(_) + | IdentityScenarioError::InvalidAction { .. } + | IdentityScenarioError::Encoding(_) => IdentityFailureClass::Execution, + }; + Self { + class, + detail: error.to_string(), + } + } +} + +/// Complete partial state and trace retained when a deterministic product failure occurs. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityFailedRunRecord { + /// Behavioral root seed required for confirmation and replay. + pub root_seed: [u8; 32], + /// Stable typed terminal evidence. + pub evidence: IdentityFailureEvidence, + /// State, scheduler, task, and invariant observations captured at termination. + pub report: IdentityRunReport, + /// Raw structured runtime trace captured at termination. + pub trace: Vec, +} + +/// Complete deterministic record for a correct fail-closed model rejection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityRejectedRunRecord { + /// Behavioral root seed required for exact replay. + pub root_seed: [u8; 32], + /// Stable typed expected-rejection evidence. + pub evidence: IdentityRejectionEvidence, + /// State, scheduler, task, and invariant observations captured after all owned tasks finish. + pub report: IdentityRunReport, + /// Raw structured runtime trace captured for exact replay. + pub trace: Vec, +} + +/// Detailed terminal result that preserves failed-run evidence instead of discarding it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IdentityRunOutcome { + /// Every scheduled action completed without a model or invariant failure. + Success(IdentityRunRecord), + /// A semantically valid scenario action was correctly rejected by the account-control model. + ExpectedRejection(IdentityRejectedRunRecord), + /// A deterministic product failure completed with a replayable partial record. + Failed(IdentityFailedRunRecord), +} + +/// Executes identity actions through the real seeded deterministic kernel. +#[derive(Clone, Copy, Debug)] +pub struct IdentityScenarioRunner; + +impl IdentityScenarioRunner { + /// Runs one validated scenario with all actions owned by structured kernel tasks. + pub fn run( + scenario: &IdentityScenario, + seed: RootSeed, + ) -> Result { + match Self::run_detailed(scenario, seed)? { + IdentityRunOutcome::Success(record) => Ok(record), + IdentityRunOutcome::ExpectedRejection(rejection) => Err( + IdentityScenarioError::ExpectedRejection(rejection.evidence.detail), + ), + IdentityRunOutcome::Failed(failure) => Err(IdentityScenarioError::Execution(format!( + "{}: {}", + failure.evidence.class.as_str(), + failure.evidence.detail + ))), + } + } + + /// Runs one scenario while retaining a complete deterministic terminal record on failure. + pub fn run_detailed( + scenario: &IdentityScenario, + seed: RootSeed, + ) -> Result { + Self::run_configured(scenario, seed, None) + } + + /// Executes the real kernel while injecting one bounded oracle counterexample. + pub fn run_with_invariant_mutation( + scenario: &IdentityScenario, + seed: RootSeed, + mutation: IdentityInvariantMutation, + ) -> Result { + Self::run_configured(scenario, seed, Some(mutation)) + } + + fn run_configured( + scenario: &IdentityScenario, + seed: RootSeed, + mutation: Option, + ) -> Result { + scenario.validate()?; + let trace = Arc::new( + TraceBuffer::new(10_000) + .map_err(|error| IdentityScenarioError::Runtime(error.to_string()))?, + ); + let kernel = Kernel::new( + KernelConfig { + max_events: 10_000, + max_scheduled_events: 2_048, + max_virtual_time: Duration::from_nanos(MAX_IDENTITY_VIRTUAL_NANOS), + max_tasks: 512, + max_trace_events: 10_000, + resource_limits: KernelResourceLimits::uniform(512), + }, + trace.clone(), + ) + .map_err(|error| IdentityScenarioError::Runtime(error.to_string()))?; + let context = kernel.runtime_context(seed, krikos_runtime::SystemTime::UNIX_EPOCH); + let group = context.executor().new_group(None); + let world = Arc::new(Mutex::new(ScenarioWorld::new(mutation)?)); + + for scheduled in scenario.actions.iter().cloned() { + let clock = context.clock(); + let recorder = context.trace(); + let world = world.clone(); + let task_name = format!("identity/{}", scheduled.id); + group + .spawn( + TaskKind::Other("identity_action".to_owned()), + &task_name, + Box::pin(async move { + let sleep = ClockSleep::after( + clock.clone(), + Duration::from_nanos(scheduled.at_nanos), + ); + let sleep_result = match sleep { + Ok(sleep) => sleep.await.map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + if let Err(error) = sleep_result { + record_task_failure(&world, format!("virtual clock: {error}")); + return; + } + let now = clock.elapsed_nanos().unwrap_or(scheduled.at_nanos); + if let Err(error) = recorder.record( + now, + TraceContext { + operation: Some(scheduled.id.clone()), + ..TraceContext::default() + }, + TraceEventKind::OperationStarted { + action: action_name(&scheduled.action).to_owned(), + }, + ) { + record_task_failure(&world, format!("trace: {error}")); + return; + } + + let (outcome, invariant_names) = execute_scheduled(&world, &scheduled); + for invariant in invariant_names { + if let Err(error) = recorder.record( + now, + TraceContext { + operation: Some(scheduled.id.clone()), + invariant: Some(invariant.clone()), + ..TraceContext::default() + }, + TraceEventKind::InvariantSatisfied { + obligation: invariant, + }, + ) { + record_task_failure(&world, format!("trace: {error}")); + return; + } + } + if let Err(error) = recorder.record( + now, + TraceContext { + operation: Some(scheduled.id), + ..TraceContext::default() + }, + TraceEventKind::OperationCompleted { outcome }, + ) { + record_task_failure(&world, format!("trace: {error}")); + } + }), + ) + .map_err(|error| IdentityScenarioError::Runtime(error.to_string()))?; + } + group.close(); + let run = kernel + .run_until_idle() + .map_err(|error| IdentityScenarioError::Runtime(error.to_string()))?; + if run.quiescence != Quiescence::Complete { + return Err(IdentityScenarioError::Runtime(format!( + "identity tasks did not complete: {:?}", + run.quiescence + ))); + } + let world = world + .lock() + .map_err(|_| IdentityScenarioError::Runtime("identity world lock poisoned".into()))?; + let report = IdentityRunReport { + schema_version: IDENTITY_SCENARIO_SCHEMA_VERSION, + scenario_id: scenario.id.clone(), + steps: world.steps.clone(), + final_state: world.model.snapshot(), + coverage: world.coverage, + invariants: world.invariants, + delivery: world.delivery.clone(), + scheduler: run.scheduler, + tasks: kernel.task_ownership_snapshot(), + events_executed: run.events_executed, + virtual_time_nanos: u64::try_from(run.virtual_time.as_nanos()).map_err(|_| { + IdentityScenarioError::Runtime("virtual time does not fit u64".into()) + })?, + }; + let trace = trace.events(); + let root_seed = *seed.as_bytes(); + Ok(if let Some(evidence) = &world.failure { + IdentityRunOutcome::Failed(IdentityFailedRunRecord { + root_seed, + evidence: evidence.clone(), + report, + trace, + }) + } else if let Some(evidence) = &world.rejection { + IdentityRunOutcome::ExpectedRejection(IdentityRejectedRunRecord { + root_seed, + evidence: evidence.clone(), + report, + trace, + }) + } else { + IdentityRunOutcome::Success(IdentityRunRecord { + root_seed, + report, + trace, + }) + }) + } +} + +#[derive(Clone, Debug)] +struct ForkBase { + predecessor: EventId, + sequence: u64, + epoch: u64, + branches: BTreeMap, +} + +#[derive(Clone, Debug)] +struct ReplicaState { + crashed: bool, + has_projection: bool, +} + +impl Default for ReplicaState { + fn default() -> Self { + Self { + crashed: false, + has_projection: true, + } + } +} + +#[derive(Debug)] +struct ScenarioWorld { + model: AccountControlModel, + next_event: u64, + fork_bases: BTreeMap, + replicas: BTreeMap, + delivery: IdentityDeliveryReport, + partitioned: bool, + provider_available: bool, + provider_consistent: bool, + pending_revocations: BTreeSet, + durable_revocations: BTreeSet, + externally_discoverable: BTreeSet, + coverage: IdentityCoverage, + invariants: IdentityInvariantCounters, + steps: Vec, + rejection: Option, + failure: Option, + invariant_mutation: Option, +} + +#[derive(Debug)] +struct IdentityInvariantObservation { + after: AccountModelSnapshot, + action: IdentityScenarioAction, + action_succeeded: bool, + outcome: String, + durable_revocations: BTreeSet, + externally_discoverable: BTreeSet, + ordinary_private_key_recipients: BTreeSet, + account_modeled_as_device: bool, +} + +impl IdentityInvariantObservation { + fn apply_mutation( + &mut self, + mutation: IdentityInvariantMutation, + before: &AccountModelSnapshot, + before_environment: &IdentityEnvironmentSnapshot, + ) -> Result { + let applied = match mutation { + IdentityInvariantMutation::AccountIsDevice => { + self.account_modeled_as_device = true; + true + } + IdentityInvariantMutation::OrdinaryPrivateKeyReplication => { + self.ordinary_private_key_recipients + .insert(DeviceId::new(1)); + true + } + IdentityInvariantMutation::DeviceNotIndependentlyRevocable => { + let IdentityScenarioAction::RevokeDevice { device, .. } = &self.action else { + return Ok(false); + }; + if !self.action_succeeded { + return Ok(false); + } + self.after + .devices + .insert(DeviceId::new(*device), DeviceLifecycle::Active); + true + } + IdentityInvariantMutation::PriorPolicyBypass => { + self.action_succeeded && clear_action_approvals(&mut self.action) + } + IdentityInvariantMutation::AccountIdentityChanged => { + self.after.account_id = [0x22_u8; 32]; + true + } + IdentityInvariantMutation::ProviderCreatedState => { + if !matches!( + &self.action, + IdentityScenarioAction::ProviderOutage + | IdentityScenarioAction::ProviderRestore + | IdentityScenarioAction::ProviderEquivocation + ) { + return Ok(false); + } + self.after.sequence = self + .after + .sequence + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?; + true + } + IdentityInvariantMutation::SocialRelationshipCreatedAuthority => { + if !matches!(&self.action, IdentityScenarioAction::SocialRelationship) { + return Ok(false); + } + self.after.sequence = self + .after + .sequence + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?; + true + } + IdentityInvariantMutation::PublishedRevocationUndiscoverable => { + let IdentityScenarioAction::PublishRevocation { subject } = &self.action else { + return Ok(false); + }; + if !self.action_succeeded { + return Ok(false); + } + self.durable_revocations.remove(subject); + true + } + IdentityInvariantMutation::OfflineValidationWithoutBasis => { + if !matches!(&self.action, IdentityScenarioAction::OfflineValidate) { + return Ok(false); + } + self.outcome = if before.forked { + "basis:mutated".into() + } else { + "no_basis".into() + }; + true + } + IdentityInvariantMutation::SensitiveActionDidNotFailClosed => { + let sensitive_is_unsafe = !before_environment.provider_available + || !before_environment.provider_consistent + || before.forked; + if !matches!(&self.action, IdentityScenarioAction::SensitiveProbe) + || !sensitive_is_unsafe + { + return Ok(false); + } + self.outcome = "allowed".into(); + true + } + IdentityInvariantMutation::RevokedDeviceReceivedGroupKey => { + let IdentityScenarioAction::RevokeDevice { device, .. } = &self.action else { + return Ok(false); + }; + if !self.action_succeeded { + return Ok(false); + } + let target = DeviceId::new(*device); + if let Err(index) = self.after.group_key_recipients.binary_search(&target) { + self.after.group_key_recipients.insert(index, target); + } + true + } + IdentityInvariantMutation::ConflictSilentlyMerged => { + if !self.after.forked || self.outcome != "forkdetected" { + return Ok(false); + } + self.after.forked = false; + self.after.heads.truncate(1); + true + } + }; + Ok(applied) + } +} + +impl ScenarioWorld { + fn new( + invariant_mutation: Option, + ) -> Result { + let controllers = vec![ + ModelController::new(ControllerId::new(1), 1)?, + ModelController::new(ControllerId::new(2), 1)?, + ]; + Ok(Self { + model: AccountControlModel::new([0x11; 32], controllers, ModelPolicy::new(1)?)?, + next_event: 1, + fork_bases: BTreeMap::new(), + replicas: BTreeMap::from([(1, ReplicaState::default())]), + delivery: IdentityDeliveryReport { + pending: vec![1, 2, 3, 4], + delivered: Vec::new(), + delayed: 0, + reordered: 0, + dropped: 0, + duplicate_deliveries: 0, + }, + partitioned: false, + provider_available: true, + provider_consistent: true, + pending_revocations: BTreeSet::new(), + durable_revocations: BTreeSet::new(), + externally_discoverable: BTreeSet::new(), + coverage: IdentityCoverage::default(), + invariants: IdentityInvariantCounters::default(), + steps: Vec::new(), + rejection: None, + failure: None, + invariant_mutation, + }) + } + + fn allocate_event(&mut self) -> Result { + let id = self.next_event; + self.next_event = self + .next_event + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?; + Ok(EventId::new(id)) + } + + fn environment_snapshot(&self) -> IdentityEnvironmentSnapshot { + IdentityEnvironmentSnapshot { + partitioned: self.partitioned, + provider_available: self.provider_available, + provider_consistent: self.provider_consistent, + replicas: self + .replicas + .iter() + .map(|(replica, state)| IdentityReplicaSnapshot { + replica: *replica, + crashed: state.crashed, + has_projection: state.has_projection, + }) + .collect(), + delivery: self.delivery.clone(), + } + } + + fn current_position(&self) -> Result<(EventId, u64, u64), IdentityScenarioError> { + let snapshot = self.model.snapshot(); + let predecessor = match snapshot.heads.as_slice() { + [] if snapshot.sequence == 0 => EventId::new(0), + [head] if !snapshot.forked => *head, + _ => return Err(IdentityScenarioError::Execution("account is forked".into())), + }; + Ok((predecessor, snapshot.sequence, snapshot.epoch)) + } + + fn apply_ordinary( + &mut self, + operation: IdentityOperation, + approvals: &[u16], + ) -> Result { + let (predecessor, sequence, epoch) = self.current_position()?; + let resulting_epoch = operation.resulting_epoch(epoch)?; + let event = IdentityEvent::new( + self.allocate_event()?, + predecessor, + sequence + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?, + resulting_epoch, + controller_ids(approvals), + operation, + )?; + let disposition = self.model.apply(&event)?; + Ok(format!("{disposition:?}").to_ascii_lowercase()) + } + + fn apply_recovery( + &mut self, + controllers: &[RecoveryController], + required_weight: u16, + ) -> Result { + if !self.provider_available || !self.provider_consistent { + return Err(IdentityScenarioError::Execution( + "recovery evidence unavailable or inconsistent".into(), + )); + } + let plan = RecoveryPlan::new( + controllers + .iter() + .map(|controller| { + ModelController::new( + ControllerId::new(controller.controller), + controller.weight, + ) + }) + .collect::, _>>()?, + ModelPolicy::new(required_weight)?, + )?; + let (predecessor, sequence, epoch) = self.current_position()?; + let event = IdentityEvent::new( + self.allocate_event()?, + predecessor, + sequence + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?, + epoch + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?, + Vec::new(), + IdentityOperation::Recover(plan), + )?; + let disposition = self.model.apply_recovery(&event)?; + Ok(format!("{disposition:?}").to_ascii_lowercase()) + } + + fn propose_fork( + &mut self, + fork: &str, + branch: &str, + approvals: &[u16], + operation: &ForkScenarioOperation, + ) -> Result { + let position = self.current_position(); + if !self.fork_bases.contains_key(fork) { + let (predecessor, sequence, epoch) = position?; + self.fork_bases.insert( + fork.to_owned(), + ForkBase { + predecessor, + sequence, + epoch, + branches: BTreeMap::new(), + }, + ); + } + let base = self + .fork_bases + .get(fork) + .cloned() + .ok_or_else(|| IdentityScenarioError::Execution("missing fork base".into()))?; + if base.branches.contains_key(branch) { + return Err(IdentityScenarioError::Execution( + "duplicate fork branch".into(), + )); + } + let event_id = self.allocate_event()?; + let event = IdentityEvent::new( + event_id, + base.predecessor, + base.sequence + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?, + base.epoch + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?, + controller_ids(approvals), + fork_operation(operation)?, + )?; + let disposition = self.model.apply(&event)?; + self.fork_bases + .get_mut(fork) + .ok_or_else(|| IdentityScenarioError::Execution("missing fork base".into()))? + .branches + .insert(branch.to_owned(), event_id); + Ok(format!("{disposition:?}").to_ascii_lowercase()) + } + + fn resolve_fork( + &mut self, + fork: &str, + selected_branch: &str, + approvals: &[u16], + revoked_controllers: &[u16], + revoked_devices: &[u16], + ) -> Result { + let base = self + .fork_bases + .get(fork) + .cloned() + .ok_or_else(|| IdentityScenarioError::Execution("unknown fork".into()))?; + let selected_head = *base + .branches + .get(selected_branch) + .ok_or_else(|| IdentityScenarioError::Execution("unknown fork branch".into()))?; + let heads = self.model.snapshot().heads; + let resolution = ForkResolution::new( + self.allocate_event()?, + heads, + selected_head, + base.sequence + .checked_add(2) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?, + base.epoch + .checked_add(2) + .ok_or(IdentityScenarioError::ArithmeticOverflow)?, + controller_ids(approvals), + controller_ids(revoked_controllers), + revoked_devices.iter().copied().map(DeviceId::new).collect(), + )?; + let disposition = self.model.resolve_fork(&resolution)?; + Ok(format!("{disposition:?}").to_ascii_lowercase()) + } + + fn check_invariants( + &mut self, + before: &AccountModelSnapshot, + before_environment: &IdentityEnvironmentSnapshot, + action: &IdentityScenarioAction, + action_succeeded: bool, + outcome: &str, + ) -> Result, IdentityScenarioError> { + let mut observation = IdentityInvariantObservation { + after: self.model.snapshot(), + action: action.clone(), + action_succeeded, + outcome: outcome.to_owned(), + durable_revocations: self.durable_revocations.clone(), + externally_discoverable: self.externally_discoverable.clone(), + ordinary_private_key_recipients: BTreeSet::new(), + account_modeled_as_device: false, + }; + if let Some(mutation) = self.invariant_mutation + && observation.apply_mutation(mutation, before, before_environment)? + { + self.invariant_mutation = None; + } + let after = &observation.after; + let action = &observation.action; + let outcome = observation.outcome.as_str(); + let active_devices = after + .devices + .iter() + .filter_map(|(id, lifecycle)| (*lifecycle == DeviceLifecycle::Active).then_some(*id)) + .collect::>(); + let recipients = after + .group_key_recipients + .iter() + .copied() + .collect::>(); + let action_is_secret_free = observation.ordinary_private_key_recipients.is_empty(); + let device_revocation_is_independent = match action { + IdentityScenarioAction::RevokeDevice { device, .. } if action_succeeded => { + let target = DeviceId::new(*device); + after.devices.get(&target) == Some(&DeviceLifecycle::Revoked) + && before.devices.iter().all(|(id, lifecycle)| { + *id == target || after.devices.get(id) == Some(lifecycle) + }) + } + _ => after.devices.keys().all(|device| device.get() != 0), + }; + let prior_policy_authorized = !action_succeeded + || action_approvals(action) + .is_none_or(|approvals| approvals_satisfy_prior_policy(before, approvals)); + let provider_action = matches!( + action, + IdentityScenarioAction::ProviderOutage + | IdentityScenarioAction::ProviderRestore + | IdentityScenarioAction::ProviderEquivocation + ); + let publication_is_discoverable = observation + .externally_discoverable + .is_subset(&observation.durable_revocations) + && match action { + IdentityScenarioAction::PublishRevocation { subject } if action_succeeded => { + observation.durable_revocations.contains(subject) + && observation.externally_discoverable.contains(subject) + } + _ => true, + }; + let offline_basis_is_valid = match action { + IdentityScenarioAction::OfflineValidate => { + let expected = if before.forked { + "no_basis".to_owned() + } else { + format!("basis:{}/{}", before.sequence, before.epoch) + }; + outcome == expected + } + _ => true, + }; + let sensitive_is_unsafe = !before_environment.provider_available + || !before_environment.provider_consistent + || before.forked; + let sensitive_failed_closed = !matches!(action, IdentityScenarioAction::SensitiveProbe) + || !sensitive_is_unsafe + || (outcome == "failed_closed" && before == after); + let conflict_shape_is_consistent = after.forked == (after.heads.len() > 1); + let newly_visible_conflict_was_reported = + before.forked || !after.forked || outcome == "forkdetected"; + let reported_conflict_is_retained = outcome != "forkdetected" || after.forked; + let prior_conflict_was_not_silently_merged = !before.forked + || if matches!(action, IdentityScenarioAction::ResolveFork { .. }) && action_succeeded { + !after.forked && after.heads.len() == 1 + } else { + after.forked + && before + .heads + .iter() + .all(|head| after.heads.binary_search(head).is_ok()) + }; + let conflicts_detected = conflict_shape_is_consistent + && newly_visible_conflict_was_reported + && reported_conflict_is_retained + && prior_conflict_was_not_silently_merged; + let results = [ + ( + "account_is_not_device", + !observation.account_modeled_as_device + && after.account_id != [0_u8; 32] + && after.devices.keys().all(|device| device.get() != 0), + ), + ("no_ordinary_private_key_replication", action_is_secret_free), + ( + "device_independently_revocable", + device_revocation_is_independent, + ), + ("prior_policy_authorization", prior_policy_authorized), + ( + "stable_account_identity", + before.account_id == after.account_id, + ), + ( + "provider_cannot_create_state", + !provider_action || before == after, + ), + ( + "social_no_implicit_authority", + !matches!(action, IdentityScenarioAction::SocialRelationship) || before == after, + ), + ( + "published_revocation_discoverability", + publication_is_discoverable, + ), + ("offline_validation_has_basis", offline_basis_is_valid), + ("sensitive_actions_fail_closed", sensitive_failed_closed), + ( + "revoked_device_excluded_from_group_keys", + recipients.is_subset(&active_devices), + ), + ("conflicts_detected_not_merged", conflicts_detected), + ]; + + increment_counter(&mut self.invariants.account_is_not_device)?; + increment_counter(&mut self.invariants.no_ordinary_private_key_replication)?; + increment_counter(&mut self.invariants.device_independently_revocable)?; + increment_counter(&mut self.invariants.prior_policy_authorization)?; + increment_counter(&mut self.invariants.stable_account_identity)?; + increment_counter(&mut self.invariants.provider_cannot_create_state)?; + increment_counter(&mut self.invariants.social_no_implicit_authority)?; + increment_counter(&mut self.invariants.published_revocation_discoverability)?; + increment_counter(&mut self.invariants.offline_validation_has_basis)?; + increment_counter(&mut self.invariants.sensitive_actions_fail_closed)?; + increment_counter(&mut self.invariants.revoked_device_excluded_from_group_keys)?; + increment_counter(&mut self.invariants.conflicts_detected_not_merged)?; + + if let Some((name, _)) = results.iter().find(|(_, satisfied)| !satisfied) { + return Err(IdentityScenarioError::Invariant((*name).to_owned())); + } + Ok(results + .into_iter() + .map(|(name, _)| format!("identity_invariant/{name}")) + .collect()) + } + + fn record_expected_rejection( + &mut self, + rejection: ExpectedModelRejection, + error: &IdentityScenarioError, + ) { + if self.rejection.is_none() { + self.rejection = Some(IdentityRejectionEvidence::new(rejection, error)); + } + } + + fn record_product_error(&mut self, error: &IdentityScenarioError) { + if self.failure.is_none() { + self.failure = Some(IdentityFailureEvidence::from_error(error)); + } + } +} + +fn execute_scheduled( + world: &Arc>, + scheduled: &IdentityAction, +) -> (String, Vec) { + let mut world = match world.lock() { + Ok(world) => world, + Err(_) => return ("error:poisoned".into(), Vec::new()), + }; + world.coverage.observe(&scheduled.action); + let before = world.model.snapshot(); + let before_environment = world.environment_snapshot(); + let result = execute_action(&mut world, &scheduled.action); + let action_succeeded = result.is_ok(); + let expected_rejection = scheduled.expectation.expected_model_rejection(); + let outcome = match result { + Ok(outcome) => match expected_rejection { + None => outcome, + Some(expected) => { + let error = IdentityScenarioError::Execution(format!( + "action {} expected model rejection {} but succeeded", + scheduled.id, + expected.as_str() + )); + let detail = error.to_string(); + world.record_product_error(&error); + format!("error:{detail}") + } + }, + Err(IdentityScenarioError::Model(model_error)) => { + let actual_rejection = ExpectedModelRejection::from_model_error(&model_error); + let error = IdentityScenarioError::Model(model_error); + if let Some(rejection) = actual_rejection + && expected_rejection == Some(rejection) + { + world.record_expected_rejection(rejection, &error); + format!("expected_rejection:{}", rejection.as_str()) + } else { + let detail = error.to_string(); + world.record_product_error(&error); + format!("error:{detail}") + } + } + Err(error) => { + let detail = error.to_string(); + world.record_product_error(&error); + format!("error:{detail}") + } + }; + let invariant_names = match world.check_invariants( + &before, + &before_environment, + &scheduled.action, + action_succeeded, + &outcome, + ) { + Ok(names) => names, + Err(error) => { + world.record_product_error(&error); + Vec::new() + } + }; + let state = world.model.snapshot(); + let environment = world.environment_snapshot(); + world.steps.push(IdentityStepReport { + action_id: scheduled.id.clone(), + outcome: outcome.clone(), + state, + environment, + }); + (outcome, invariant_names) +} + +fn execute_action( + world: &mut ScenarioWorld, + action: &IdentityScenarioAction, +) -> Result { + match action { + IdentityScenarioAction::Partition => { + world.partitioned = true; + Ok("partitioned".into()) + } + IdentityScenarioAction::Heal => { + world.partitioned = false; + let pending = std::mem::take(&mut world.delivery.pending); + world.delivery.delivered.extend(pending); + Ok("healed".into()) + } + IdentityScenarioAction::DeliveryFault { fault } => match fault { + IdentityDeliveryFault::Delay => { + if world.delivery.pending.is_empty() { + return Err(IdentityScenarioError::Execution( + "delay fault has no pending delivery".into(), + )); + } + world.delivery.pending.rotate_left(1); + world.delivery.delayed = checked_increment(world.delivery.delayed)?; + Ok("fault:delay".into()) + } + IdentityDeliveryFault::Reorder => { + if world.delivery.pending.len() < 2 { + return Err(IdentityScenarioError::Execution( + "reorder fault needs two pending deliveries".into(), + )); + } + world.delivery.pending.swap(0, 1); + world.delivery.reordered = checked_increment(world.delivery.reordered)?; + Ok("fault:reorder".into()) + } + IdentityDeliveryFault::Loss => { + if world.delivery.pending.is_empty() { + return Err(IdentityScenarioError::Execution( + "loss fault has no pending delivery".into(), + )); + } + world.delivery.pending.remove(0); + world.delivery.dropped = checked_increment(world.delivery.dropped)?; + Ok("fault:loss".into()) + } + IdentityDeliveryFault::Duplicate => { + if world.delivery.pending.is_empty() + || world.delivery.pending.len() >= MAX_IDENTITY_DELIVERIES + { + return Err(IdentityScenarioError::Execution( + "duplicate fault exceeded the pending-delivery bound".into(), + )); + } + let duplicate = world.delivery.pending[0]; + world.delivery.pending.insert(1, duplicate); + world.delivery.duplicate_deliveries = + checked_increment(world.delivery.duplicate_deliveries)?; + Ok("fault:duplicate".into()) + } + }, + IdentityScenarioAction::AddController { + controller, + weight, + approvals, + } => world.apply_ordinary( + IdentityOperation::AddController(ModelController::new( + ControllerId::new(*controller), + *weight, + )?), + approvals, + ), + IdentityScenarioAction::ChangePolicy { + required_weight, + approvals, + } => world.apply_ordinary( + IdentityOperation::ChangePolicy(ModelPolicy::new(*required_weight)?), + approvals, + ), + IdentityScenarioAction::AuthorizeDevice { device, approvals } => world.apply_ordinary( + IdentityOperation::AuthorizeDevice(DeviceId::new(*device)), + approvals, + ), + IdentityScenarioAction::RevokeDevice { device, approvals } => { + let outcome = world.apply_ordinary( + IdentityOperation::RevokeDevice(DeviceId::new(*device)), + approvals, + )?; + world.pending_revocations.insert(format!("device:{device}")); + Ok(outcome) + } + IdentityScenarioAction::RevokeController { + controller, + approvals, + } => { + let outcome = world.apply_ordinary( + IdentityOperation::RevokeController(ControllerId::new(*controller)), + approvals, + )?; + world + .pending_revocations + .insert(format!("controller:{controller}")); + Ok(outcome) + } + IdentityScenarioAction::ForkProposal { + fork, + branch, + approvals, + operation, + } => world.propose_fork(fork, branch, approvals, operation), + IdentityScenarioAction::ResolveFork { + fork, + selected_branch, + approvals, + revoked_controllers, + revoked_devices, + } => world.resolve_fork( + fork, + selected_branch, + approvals, + revoked_controllers, + revoked_devices, + ), + IdentityScenarioAction::Crash { replica } => { + let replica = world.replicas.entry(*replica).or_default(); + replica.crashed = true; + Ok("crashed".into()) + } + IdentityScenarioAction::Reopen { + replica, + storage_loss, + } => { + let replica = world.replicas.entry(*replica).or_default(); + replica.crashed = false; + if *storage_loss { + replica.has_projection = false; + } + Ok("reopened".into()) + } + IdentityScenarioAction::ProviderOutage => { + world.provider_available = false; + Ok("provider_unavailable".into()) + } + IdentityScenarioAction::ProviderRestore => { + world.provider_available = true; + world.provider_consistent = true; + Ok("provider_restored".into()) + } + IdentityScenarioAction::ProviderEquivocation => { + world.provider_consistent = false; + Ok("equivocation_detected".into()) + } + IdentityScenarioAction::SensitiveProbe => { + if !world.provider_available + || !world.provider_consistent + || world.model.snapshot().forked + { + Ok("failed_closed".into()) + } else { + Ok("admissible".into()) + } + } + IdentityScenarioAction::Recover { + controllers, + required_weight, + } => world.apply_recovery(controllers, *required_weight), + IdentityScenarioAction::Migration { phase, approvals } => { + let operation = match phase { + MigrationPhase::Begin => IdentityOperation::BeginMigration, + MigrationPhase::Activate => IdentityOperation::ActivateMigration, + MigrationPhase::Complete => IdentityOperation::CompleteMigration, + }; + world.apply_ordinary(operation, approvals) + } + IdentityScenarioAction::RotateGroupKey { approvals } => { + world.apply_ordinary(IdentityOperation::RotateGroupKey, approvals) + } + IdentityScenarioAction::PublishRevocation { subject } => { + if !world.pending_revocations.contains(subject) { + return Err(IdentityScenarioError::Execution(format!( + "revocation {subject} is not pending" + ))); + } + world.durable_revocations.insert(subject.clone()); + world.externally_discoverable.insert(subject.clone()); + Ok("durably_published".into()) + } + IdentityScenarioAction::OfflineValidate => { + let state = world.model.snapshot(); + if state.forked { + Ok("no_basis".into()) + } else { + Ok(format!("basis:{}/{}", state.sequence, state.epoch)) + } + } + IdentityScenarioAction::SocialRelationship => Ok("no_authority".into()), + IdentityScenarioAction::InvariantFault { mutation } => { + world.invariant_mutation = Some(*mutation); + Ok("fault_injected".into()) + } + } +} + +impl IdentityCoverage { + fn observe(&mut self, action: &IdentityScenarioAction) { + match action { + IdentityScenarioAction::Partition => self.partition = true, + IdentityScenarioAction::Heal => self.heal = true, + IdentityScenarioAction::DeliveryFault { fault } => match fault { + IdentityDeliveryFault::Delay => self.delay = true, + IdentityDeliveryFault::Reorder => self.reorder = true, + IdentityDeliveryFault::Loss => self.loss = true, + IdentityDeliveryFault::Duplicate => self.duplicate = true, + }, + IdentityScenarioAction::ForkProposal { .. } => self.fork = true, + IdentityScenarioAction::ResolveFork { .. } => self.fork_resolution = true, + IdentityScenarioAction::Crash { .. } => self.crash = true, + IdentityScenarioAction::Reopen { storage_loss, .. } => { + self.reopen = true; + self.storage_loss |= *storage_loss; + } + IdentityScenarioAction::ProviderOutage => self.provider_outage = true, + IdentityScenarioAction::ProviderEquivocation => self.provider_equivocation = true, + IdentityScenarioAction::Recover { .. } => self.recovery = true, + IdentityScenarioAction::RevokeController { .. } => self.controller_revocation = true, + IdentityScenarioAction::RevokeDevice { .. } => self.device_revocation = true, + IdentityScenarioAction::Migration { phase, .. } => match phase { + MigrationPhase::Begin => self.migration_begin = true, + MigrationPhase::Activate => self.migration_activate = true, + MigrationPhase::Complete => self.migration_complete = true, + }, + IdentityScenarioAction::RotateGroupKey { .. } => self.group_key_rotation = true, + IdentityScenarioAction::AddController { .. } + | IdentityScenarioAction::ChangePolicy { .. } + | IdentityScenarioAction::AuthorizeDevice { .. } + | IdentityScenarioAction::ProviderRestore + | IdentityScenarioAction::SensitiveProbe + | IdentityScenarioAction::PublishRevocation { .. } + | IdentityScenarioAction::OfflineValidate + | IdentityScenarioAction::SocialRelationship + | IdentityScenarioAction::InvariantFault { .. } => {} + } + } +} + +fn fork_operation( + operation: &ForkScenarioOperation, +) -> Result { + Ok(match operation { + ForkScenarioOperation::AddController { controller, weight } => { + IdentityOperation::AddController(ModelController::new( + ControllerId::new(*controller), + *weight, + )?) + } + ForkScenarioOperation::AuthorizeDevice { device } => { + IdentityOperation::AuthorizeDevice(DeviceId::new(*device)) + } + ForkScenarioOperation::ChangePolicy { required_weight } => { + IdentityOperation::ChangePolicy(ModelPolicy::new(*required_weight)?) + } + }) +} + +fn controller_ids(ids: &[u16]) -> Vec { + ids.iter().copied().map(ControllerId::new).collect() +} + +fn checked_increment(value: u64) -> Result { + value + .checked_add(1) + .ok_or(IdentityScenarioError::ArithmeticOverflow) +} + +fn increment_counter(value: &mut u64) -> Result<(), IdentityScenarioError> { + *value = checked_increment(*value)?; + Ok(()) +} + +fn validate_text(value: &str) -> Result<(), IdentityScenarioError> { + if value.is_empty() + || value.len() > MAX_IDENTITY_TEXT_BYTES + || value.bytes().any(|byte| { + !(byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b':' | b'.' | b'_' | b'-')) + }) + { + return Err(IdentityScenarioError::InvalidText(value.to_owned())); + } + Ok(()) +} + +fn validate_action_semantics( + action_id: &str, + action: &IdentityScenarioAction, +) -> Result<(), IdentityScenarioError> { + let valid_controller = |controller: u16| controller != 0; + let valid_device = |device: u16| device != 0; + match action { + IdentityScenarioAction::AddController { + controller, + weight, + approvals, + } => { + if !valid_controller(*controller) || *weight == 0 { + return Err(invalid_action( + action_id, + "controller identity and weight must be nonzero", + )); + } + validate_bounded_ids(action_id, approvals, MAX_MODEL_CONTROLLERS, "approvals")?; + } + IdentityScenarioAction::ChangePolicy { + required_weight, + approvals, + } => { + if *required_weight == 0 { + return Err(invalid_action( + action_id, + "required policy weight must be nonzero", + )); + } + validate_bounded_ids(action_id, approvals, MAX_MODEL_CONTROLLERS, "approvals")?; + } + IdentityScenarioAction::AuthorizeDevice { device, approvals } + | IdentityScenarioAction::RevokeDevice { device, approvals } => { + if !valid_device(*device) { + return Err(invalid_action(action_id, "device identity must be nonzero")); + } + validate_bounded_ids(action_id, approvals, MAX_MODEL_CONTROLLERS, "approvals")?; + } + IdentityScenarioAction::RevokeController { + controller, + approvals, + } => { + if !valid_controller(*controller) { + return Err(invalid_action( + action_id, + "controller identity must be nonzero", + )); + } + validate_bounded_ids(action_id, approvals, MAX_MODEL_CONTROLLERS, "approvals")?; + } + IdentityScenarioAction::ForkProposal { + approvals, + operation, + .. + } => { + validate_bounded_ids(action_id, approvals, MAX_MODEL_CONTROLLERS, "approvals")?; + match operation { + ForkScenarioOperation::AddController { controller, weight } => { + if !valid_controller(*controller) || *weight == 0 { + return Err(invalid_action( + action_id, + "fork controller identity and weight must be nonzero", + )); + } + } + ForkScenarioOperation::AuthorizeDevice { device } => { + if !valid_device(*device) { + return Err(invalid_action( + action_id, + "fork device identity must be nonzero", + )); + } + } + ForkScenarioOperation::ChangePolicy { required_weight } => { + if *required_weight == 0 { + return Err(invalid_action( + action_id, + "fork policy weight must be nonzero", + )); + } + } + } + } + IdentityScenarioAction::ResolveFork { + approvals, + revoked_controllers, + revoked_devices, + .. + } => { + validate_bounded_ids(action_id, approvals, MAX_MODEL_CONTROLLERS, "approvals")?; + validate_bounded_ids( + action_id, + revoked_controllers, + MAX_MODEL_CONTROLLERS, + "revoked controllers", + )?; + validate_bounded_ids( + action_id, + revoked_devices, + MAX_MODEL_DEVICES, + "revoked devices", + )?; + } + IdentityScenarioAction::Crash { replica } + | IdentityScenarioAction::Reopen { replica, .. } => { + if *replica == 0 { + return Err(invalid_action( + action_id, + "replica identity must be nonzero", + )); + } + } + IdentityScenarioAction::Recover { + controllers, + required_weight, + } => { + if controllers.is_empty() + || controllers.len() > MAX_MODEL_CONTROLLERS + || *required_weight == 0 + { + return Err(invalid_action( + action_id, + "recovery authority and threshold must be nonempty and bounded", + )); + } + let mut seen = BTreeSet::new(); + let mut total_weight = 0_u16; + for controller in controllers { + if !valid_controller(controller.controller) + || controller.weight == 0 + || !seen.insert(controller.controller) + { + return Err(invalid_action( + action_id, + "recovery controllers must be unique with nonzero identities and weights", + )); + } + total_weight = total_weight.checked_add(controller.weight).ok_or_else(|| { + invalid_action( + action_id, + "recovery authority weight exceeds model representation", + ) + })?; + } + if *required_weight > total_weight { + return Err(invalid_action( + action_id, + "recovery threshold exceeds declared authority weight", + )); + } + } + IdentityScenarioAction::Migration { approvals, .. } + | IdentityScenarioAction::RotateGroupKey { approvals } => { + validate_bounded_ids(action_id, approvals, MAX_MODEL_CONTROLLERS, "approvals")?; + } + IdentityScenarioAction::Partition + | IdentityScenarioAction::Heal + | IdentityScenarioAction::DeliveryFault { .. } + | IdentityScenarioAction::ProviderOutage + | IdentityScenarioAction::ProviderRestore + | IdentityScenarioAction::ProviderEquivocation + | IdentityScenarioAction::SensitiveProbe + | IdentityScenarioAction::PublishRevocation { .. } + | IdentityScenarioAction::OfflineValidate + | IdentityScenarioAction::SocialRelationship + | IdentityScenarioAction::InvariantFault { .. } => {} + } + Ok(()) +} + +fn validate_action_expectation( + action_id: &str, + action: &IdentityScenarioAction, + expectation: IdentityActionExpectation, +) -> Result<(), IdentityScenarioError> { + let Some(rejection) = expectation.expected_model_rejection() else { + return Ok(()); + }; + let prior_policy_action = action_approvals(action).is_some(); + let adds_controller = matches!( + action, + IdentityScenarioAction::AddController { .. } + | IdentityScenarioAction::ForkProposal { + operation: ForkScenarioOperation::AddController { .. }, + .. + } + ); + let authorizes_device = matches!( + action, + IdentityScenarioAction::AuthorizeDevice { .. } + | IdentityScenarioAction::ForkProposal { + operation: ForkScenarioOperation::AuthorizeDevice { .. }, + .. + } + ); + let permitted = match rejection { + ExpectedModelRejection::InsufficientWeight | ExpectedModelRejection::RevokedController => { + prior_policy_action + } + ExpectedModelRejection::UnknownController => { + prior_policy_action + || matches!( + action, + IdentityScenarioAction::RevokeController { .. } + | IdentityScenarioAction::ResolveFork { .. } + ) + } + ExpectedModelRejection::ControllerAlreadyKnown + | ExpectedModelRejection::ControllerLimitExceeded => adds_controller, + ExpectedModelRejection::UnsatisfiedPolicy => matches!( + action, + IdentityScenarioAction::ChangePolicy { .. } + | IdentityScenarioAction::RevokeController { .. } + | IdentityScenarioAction::ResolveFork { .. } + | IdentityScenarioAction::Recover { .. } + ), + ExpectedModelRejection::DeviceLimitExceeded => authorizes_device, + ExpectedModelRejection::EventLimitExceeded => matches!( + action, + IdentityScenarioAction::AddController { .. } + | IdentityScenarioAction::ChangePolicy { .. } + | IdentityScenarioAction::AuthorizeDevice { .. } + | IdentityScenarioAction::RevokeDevice { .. } + | IdentityScenarioAction::RevokeController { .. } + | IdentityScenarioAction::ForkProposal { .. } + | IdentityScenarioAction::Recover { .. } + | IdentityScenarioAction::Migration { .. } + | IdentityScenarioAction::RotateGroupKey { .. } + ), + ExpectedModelRejection::UnknownDevice => matches!( + action, + IdentityScenarioAction::RevokeDevice { .. } + | IdentityScenarioAction::ResolveFork { .. } + ), + ExpectedModelRejection::DeviceAlreadyKnown => { + authorizes_device || matches!(action, IdentityScenarioAction::RevokeDevice { .. }) + } + ExpectedModelRejection::RecoveryReintroducesRevokedController => { + matches!(action, IdentityScenarioAction::Recover { .. }) + } + ExpectedModelRejection::InvalidMigration => { + matches!(action, IdentityScenarioAction::Migration { .. }) + } + ExpectedModelRejection::InvalidForkResolution => { + matches!(action, IdentityScenarioAction::ResolveFork { .. }) + } + }; + if !permitted { + return Err(invalid_action( + action_id, + "model-rejection expectation is not valid for this action kind", + )); + } + Ok(()) +} + +fn validate_bounded_ids( + action_id: &str, + values: &[u16], + maximum: usize, + field: &'static str, +) -> Result<(), IdentityScenarioError> { + if values.len() > maximum { + return Err(invalid_action( + action_id, + match field { + "approvals" => "approvals must be unique, nonzero, and bounded", + "revoked controllers" => "revoked controllers must be unique, nonzero, and bounded", + "revoked devices" => "revoked devices must be unique, nonzero, and bounded", + _ => "identity list must be unique, nonzero, and bounded", + }, + )); + } + let unique = values.iter().copied().collect::>(); + if unique.len() != values.len() || unique.contains(&0) { + return Err(invalid_action( + action_id, + match field { + "approvals" => "approvals must be unique, nonzero, and bounded", + "revoked controllers" => "revoked controllers must be unique, nonzero, and bounded", + "revoked devices" => "revoked devices must be unique, nonzero, and bounded", + _ => "identity list must be unique, nonzero, and bounded", + }, + )); + } + Ok(()) +} + +fn invalid_action(action_id: &str, reason: &'static str) -> IdentityScenarioError { + IdentityScenarioError::InvalidAction { + action: action_id.to_owned(), + reason, + } +} + +fn action_text_fields(action: &IdentityScenarioAction) -> Vec<&str> { + match action { + IdentityScenarioAction::ForkProposal { fork, branch, .. } => vec![fork, branch], + IdentityScenarioAction::ResolveFork { + fork, + selected_branch, + .. + } => vec![fork, selected_branch], + IdentityScenarioAction::PublishRevocation { subject } => vec![subject], + _ => Vec::new(), + } +} + +fn action_name(action: &IdentityScenarioAction) -> &'static str { + match action { + IdentityScenarioAction::Partition => "partition", + IdentityScenarioAction::Heal => "heal", + IdentityScenarioAction::DeliveryFault { .. } => "delivery_fault", + IdentityScenarioAction::AddController { .. } => "add_controller", + IdentityScenarioAction::ChangePolicy { .. } => "change_policy", + IdentityScenarioAction::AuthorizeDevice { .. } => "authorize_device", + IdentityScenarioAction::RevokeDevice { .. } => "revoke_device", + IdentityScenarioAction::RevokeController { .. } => "revoke_controller", + IdentityScenarioAction::ForkProposal { .. } => "fork_proposal", + IdentityScenarioAction::ResolveFork { .. } => "resolve_fork", + IdentityScenarioAction::Crash { .. } => "crash", + IdentityScenarioAction::Reopen { .. } => "reopen", + IdentityScenarioAction::ProviderOutage => "provider_outage", + IdentityScenarioAction::ProviderRestore => "provider_restore", + IdentityScenarioAction::ProviderEquivocation => "provider_equivocation", + IdentityScenarioAction::SensitiveProbe => "sensitive_probe", + IdentityScenarioAction::Recover { .. } => "recover", + IdentityScenarioAction::Migration { .. } => "migration", + IdentityScenarioAction::RotateGroupKey { .. } => "rotate_group_key", + IdentityScenarioAction::PublishRevocation { .. } => "publish_revocation", + IdentityScenarioAction::OfflineValidate => "offline_validate", + IdentityScenarioAction::SocialRelationship => "social_relationship", + IdentityScenarioAction::InvariantFault { .. } => "invariant_fault", + } +} + +fn action_approvals(action: &IdentityScenarioAction) -> Option<&[u16]> { + match action { + IdentityScenarioAction::AddController { approvals, .. } + | IdentityScenarioAction::ChangePolicy { approvals, .. } + | IdentityScenarioAction::AuthorizeDevice { approvals, .. } + | IdentityScenarioAction::RevokeDevice { approvals, .. } + | IdentityScenarioAction::RevokeController { approvals, .. } + | IdentityScenarioAction::ForkProposal { approvals, .. } + | IdentityScenarioAction::ResolveFork { approvals, .. } + | IdentityScenarioAction::Migration { approvals, .. } + | IdentityScenarioAction::RotateGroupKey { approvals } => Some(approvals), + IdentityScenarioAction::Partition + | IdentityScenarioAction::Heal + | IdentityScenarioAction::DeliveryFault { .. } + | IdentityScenarioAction::Crash { .. } + | IdentityScenarioAction::Reopen { .. } + | IdentityScenarioAction::ProviderOutage + | IdentityScenarioAction::ProviderRestore + | IdentityScenarioAction::ProviderEquivocation + | IdentityScenarioAction::SensitiveProbe + | IdentityScenarioAction::Recover { .. } + | IdentityScenarioAction::PublishRevocation { .. } + | IdentityScenarioAction::OfflineValidate + | IdentityScenarioAction::SocialRelationship + | IdentityScenarioAction::InvariantFault { .. } => None, + } +} + +fn clear_action_approvals(action: &mut IdentityScenarioAction) -> bool { + let approvals = match action { + IdentityScenarioAction::AddController { approvals, .. } + | IdentityScenarioAction::ChangePolicy { approvals, .. } + | IdentityScenarioAction::AuthorizeDevice { approvals, .. } + | IdentityScenarioAction::RevokeDevice { approvals, .. } + | IdentityScenarioAction::RevokeController { approvals, .. } + | IdentityScenarioAction::ForkProposal { approvals, .. } + | IdentityScenarioAction::ResolveFork { approvals, .. } + | IdentityScenarioAction::Migration { approvals, .. } + | IdentityScenarioAction::RotateGroupKey { approvals } => approvals, + IdentityScenarioAction::Partition + | IdentityScenarioAction::Heal + | IdentityScenarioAction::DeliveryFault { .. } + | IdentityScenarioAction::Crash { .. } + | IdentityScenarioAction::Reopen { .. } + | IdentityScenarioAction::ProviderOutage + | IdentityScenarioAction::ProviderRestore + | IdentityScenarioAction::ProviderEquivocation + | IdentityScenarioAction::SensitiveProbe + | IdentityScenarioAction::Recover { .. } + | IdentityScenarioAction::PublishRevocation { .. } + | IdentityScenarioAction::OfflineValidate + | IdentityScenarioAction::SocialRelationship + | IdentityScenarioAction::InvariantFault { .. } => return false, + }; + if approvals.is_empty() { + return false; + } + approvals.clear(); + true +} + +fn approvals_satisfy_prior_policy(before: &AccountModelSnapshot, approvals: &[u16]) -> bool { + let mut seen = BTreeSet::new(); + let mut weight = 0_u64; + for approval in approvals { + let id = ControllerId::new(*approval); + if !seen.insert(id) { + return false; + } + let Some(controller) = before + .active_controllers + .iter() + .find(|controller| controller.id() == id) + else { + return false; + }; + let Some(next) = weight.checked_add(u64::from(controller.weight())) else { + return false; + }; + weight = next; + } + weight >= u64::from(before.policy.required_weight()) +} + +fn record_task_failure(world: &Arc>, error: String) { + if let Ok(mut world) = world.lock() + && world.failure.is_none() + { + world.failure = Some(IdentityFailureEvidence { + class: IdentityFailureClass::Runtime, + detail: error, + }); + } +} + +/// Invalid scenario input, model transition, invariant, or deterministic runtime failure. +#[derive(Debug, thiserror::Error)] +pub enum IdentityScenarioError { + #[error("unsupported identity scenario schema {0}")] + UnsupportedSchema(u16), + #[error("identity scenario input has {actual} bytes; maximum is {maximum}")] + InputTooLarge { actual: usize, maximum: usize }, + #[error("invalid identity scenario text {0:?}")] + InvalidText(String), + #[error("identity action count {0} is outside the bounded range")] + InvalidActionCount(usize), + #[error("duplicate identity action {0}")] + DuplicateAction(String), + #[error("identity action virtual time {0} exceeds the hard bound")] + InvalidVirtualTime(u64), + #[error("identity action {action:?} is invalid: {reason}")] + InvalidAction { + action: String, + reason: &'static str, + }, + #[error("identity scenario encoding failed: {0}")] + Encoding(String), + #[error("identity model failed: {0}")] + Model(#[from] ModelError), + /// A correctly declared model rejection reached the non-detailed runner API. + #[error("identity scenario reached an expected rejection: {0}")] + ExpectedRejection(String), + #[error("identity scenario execution failed: {0}")] + Execution(String), + #[error("identity invariant failed: {0}")] + Invariant(String), + #[error("identity scenario arithmetic overflow")] + ArithmeticOverflow, + #[error("identity deterministic runtime failed: {0}")] + Runtime(String), +} diff --git a/krikos-sim/src/lib.rs b/krikos-sim/src/lib.rs index 17d0a772b08..96b479b9fbf 100644 --- a/krikos-sim/src/lib.rs +++ b/krikos-sim/src/lib.rs @@ -41,6 +41,7 @@ mod trace; pub mod engine; pub mod evidence; pub mod execution; +pub mod identity; pub mod model; #[path = "operations_api.rs"] pub mod operations; diff --git a/krikos-sim/tests/identity.rs b/krikos-sim/tests/identity.rs new file mode 100644 index 00000000000..b40bc1524db --- /dev/null +++ b/krikos-sim/tests/identity.rs @@ -0,0 +1,1954 @@ +use krikos_runtime::RootSeed; +use krikos_sim::{ + evidence::{ + ArtifactStore, BackendCapabilities, CryptoMode, DeterminismGrade, MANIFEST_SCHEMA_VERSION, + RunBudgets, RunManifest, SIMULATOR_VERSION, SourceIdentity, TraceComparisonMode, + }, + identity::{ + AccountControlModel, ApplyDisposition, ControllerId, DeviceId, DifferentialError, EventId, + ExpectedModelRejection, ForkResolution, ForkScenarioOperation, FormalMutation, + FormalProperty, IdentityAction, IdentityArtifactBundle, IdentityCorpus, + IdentityDeliveryFault, IdentityEvent, IdentityFailureClass, IdentityFailureSignature, + IdentityInvariantMutation, IdentityMinimizer, IdentityOperation, IdentityRejectionClass, + IdentityRunOutcome, IdentityScenario, IdentityScenarioAction, IdentityScenarioError, + IdentityScenarioRunner, MAX_IDENTITY_SCENARIO_BYTES, MigrationPhase, MigrationState, + ModelController, ModelError, ModelPolicy, RecoveryController, RecoveryPlan, + check_account_control_model, check_formal_mutation, replay_identity_artifacts, + run_differential_history, + }, +}; + +fn controller(id: u16, weight: u16) -> ModelController { + ModelController::new(ControllerId::new(id), weight).unwrap() +} + +fn model(required_weight: u16) -> AccountControlModel { + AccountControlModel::new( + [0x11; 32], + vec![controller(1, 1), controller(2, 1)], + ModelPolicy::new(required_weight).unwrap(), + ) + .unwrap() +} + +fn event( + id: u64, + predecessor: u64, + sequence: u64, + approvals: &[u16], + operation: IdentityOperation, +) -> IdentityEvent { + event_at_epoch(id, predecessor, sequence, sequence, approvals, operation) +} + +fn event_at_epoch( + id: u64, + predecessor: u64, + sequence: u64, + resulting_epoch: u64, + approvals: &[u16], + operation: IdentityOperation, +) -> IdentityEvent { + IdentityEvent::new( + EventId::new(id), + EventId::new(predecessor), + sequence, + resulting_epoch, + approvals.iter().copied().map(ControllerId::new).collect(), + operation, + ) + .unwrap() +} + +#[test] +fn policy_change_is_authorized_by_the_prior_threshold_and_rejection_is_atomic() { + let mut state = model(2); + let insufficient = event( + 1, + 0, + 1, + &[1], + IdentityOperation::ChangePolicy(ModelPolicy::new(1).unwrap()), + ); + let before = state.snapshot(); + + assert_eq!( + state.apply(&insufficient), + Err(ModelError::InsufficientWeight { + actual: 1, + required: 2, + }) + ); + assert_eq!(state.snapshot(), before); + + let sufficient = event( + 2, + 0, + 1, + &[1, 2], + IdentityOperation::ChangePolicy(ModelPolicy::new(1).unwrap()), + ); + assert_eq!(state.apply(&sufficient).unwrap(), ApplyDisposition::Applied); + assert_eq!(state.snapshot().policy.required_weight(), 1); +} + +#[test] +fn revoked_controller_cannot_authorize_future_state() { + let mut state = model(1); + state + .apply(&event( + 1, + 0, + 1, + &[1], + IdentityOperation::RevokeController(ControllerId::new(2)), + )) + .unwrap(); + let before = state.snapshot(); + + assert_eq!( + state.apply(&event( + 2, + 1, + 2, + &[2], + IdentityOperation::AddController(controller(3, 1)), + )), + Err(ModelError::RevokedController(ControllerId::new(2))) + ); + assert_eq!(state.snapshot(), before); +} + +#[test] +fn concurrent_child_is_detected_as_a_fork_and_never_selected_by_arrival_order() { + let mut first_order = model(1); + let left = event( + 1, + 0, + 1, + &[1], + IdentityOperation::AddController(controller(3, 1)), + ); + let right = event( + 2, + 0, + 1, + &[2], + IdentityOperation::AuthorizeDevice(DeviceId::new(7)), + ); + assert_eq!(first_order.apply(&left).unwrap(), ApplyDisposition::Applied); + assert_eq!( + first_order.apply(&right).unwrap(), + ApplyDisposition::ForkDetected + ); + + let mut reverse_order = model(1); + assert_eq!( + reverse_order.apply(&right).unwrap(), + ApplyDisposition::Applied + ); + assert_eq!( + reverse_order.apply(&left).unwrap(), + ApplyDisposition::ForkDetected + ); + + assert!(first_order.snapshot().forked); + assert!(reverse_order.snapshot().forked); + assert_eq!( + first_order.snapshot().heads, + [EventId::new(1), EventId::new(2)] + ); + assert_eq!( + reverse_order.snapshot().heads, + [EventId::new(1), EventId::new(2)] + ); +} + +#[test] +fn explicit_fork_resolution_selects_one_branch_and_consumes_every_declared_head() { + let mut state = model(1); + let left = event( + 1, + 0, + 1, + &[1], + IdentityOperation::AddController(controller(3, 1)), + ); + let right = event( + 2, + 0, + 1, + &[2], + IdentityOperation::AuthorizeDevice(DeviceId::new(7)), + ); + state.apply(&left).unwrap(); + state.apply(&right).unwrap(); + + let resolution = ForkResolution::new( + EventId::new(3), + vec![EventId::new(2), EventId::new(1)], + EventId::new(2), + 2, + 2, + vec![ControllerId::new(1)], + vec![ControllerId::new(2)], + Vec::new(), + ) + .unwrap(); + assert_eq!( + state.resolve_fork(&resolution).unwrap(), + ApplyDisposition::Applied + ); + assert_eq!( + state.resolve_fork(&resolution).unwrap(), + ApplyDisposition::Replay + ); + + let snapshot = state.snapshot(); + assert!(!snapshot.forked); + assert_eq!(snapshot.heads, [EventId::new(3)]); + assert_eq!( + snapshot.devices.get(&DeviceId::new(7)), + Some(&krikos_sim::identity::DeviceLifecycle::Active) + ); + assert_eq!(snapshot.active_controllers, [controller(1, 1)]); + assert!(snapshot.revoked_controllers.contains(&ControllerId::new(2))); +} + +#[test] +fn migration_phases_are_ordered_and_an_invalid_phase_is_atomic() { + let mut state = model(1); + let before = state.snapshot(); + assert_eq!( + state.apply(&event(1, 0, 1, &[1], IdentityOperation::ActivateMigration,)), + Err(ModelError::InvalidMigration) + ); + assert_eq!(state.snapshot(), before); + + state + .apply(&event_at_epoch( + 2, + 0, + 1, + 0, + &[1], + IdentityOperation::BeginMigration, + )) + .unwrap(); + state + .apply(&event_at_epoch( + 3, + 2, + 2, + 1, + &[1], + IdentityOperation::ActivateMigration, + )) + .unwrap(); + state + .apply(&event_at_epoch( + 4, + 3, + 3, + 2, + &[1], + IdentityOperation::CompleteMigration, + )) + .unwrap(); + assert_eq!(state.snapshot().migration, MigrationState::Complete); +} + +#[test] +fn recovery_replaces_authority_and_revoked_devices_never_receive_future_group_keys() { + let mut state = model(1); + state + .apply(&event( + 1, + 0, + 1, + &[1], + IdentityOperation::AuthorizeDevice(DeviceId::new(7)), + )) + .unwrap(); + state + .apply(&event( + 2, + 1, + 2, + &[1], + IdentityOperation::AuthorizeDevice(DeviceId::new(8)), + )) + .unwrap(); + state + .apply(&event( + 3, + 2, + 3, + &[1], + IdentityOperation::RevokeDevice(DeviceId::new(7)), + )) + .unwrap(); + state + .apply(&event(4, 3, 4, &[1], IdentityOperation::RotateGroupKey)) + .unwrap(); + assert_eq!(state.snapshot().group_key_recipients, [DeviceId::new(8)]); + + let plan = RecoveryPlan::new(vec![controller(9, 2)], ModelPolicy::new(2).unwrap()).unwrap(); + state + .apply_recovery(&event(5, 4, 5, &[], IdentityOperation::Recover(plan))) + .unwrap(); + let snapshot = state.snapshot(); + assert_eq!(snapshot.active_controllers, [controller(9, 2)]); + assert!(snapshot.revoked_controllers.contains(&ControllerId::new(1))); + assert!(snapshot.revoked_controllers.contains(&ControllerId::new(2))); + assert_eq!( + snapshot.devices.get(&DeviceId::new(7)), + Some(&krikos_sim::identity::DeviceLifecycle::Revoked) + ); + assert_eq!( + snapshot.devices.get(&DeviceId::new(8)), + Some(&krikos_sim::identity::DeviceLifecycle::Revoked) + ); + assert!(snapshot.group_key_recipients.is_empty()); +} + +#[test] +fn root_seed_type_is_available_for_generated_history_and_scenario_replay() { + let seed = RootSeed::new([0x42; 32]); + assert_eq!(seed.as_bytes(), &[0x42; 32]); +} + +fn compound_scenario() -> IdentityScenario { + let at = 10_u64; + IdentityScenario::new( + "identity/compound", + vec![ + IdentityAction::new("partition", 0, IdentityScenarioAction::Partition).unwrap(), + IdentityAction::new( + "delay", + 1, + IdentityScenarioAction::DeliveryFault { + fault: IdentityDeliveryFault::Delay, + }, + ) + .unwrap(), + IdentityAction::new( + "reorder", + 2, + IdentityScenarioAction::DeliveryFault { + fault: IdentityDeliveryFault::Reorder, + }, + ) + .unwrap(), + IdentityAction::new( + "loss", + 3, + IdentityScenarioAction::DeliveryFault { + fault: IdentityDeliveryFault::Loss, + }, + ) + .unwrap(), + IdentityAction::new( + "duplicate", + 4, + IdentityScenarioAction::DeliveryFault { + fault: IdentityDeliveryFault::Duplicate, + }, + ) + .unwrap(), + IdentityAction::new("heal", 5, IdentityScenarioAction::Heal).unwrap(), + IdentityAction::new( + "device-7", + 6, + IdentityScenarioAction::AuthorizeDevice { + device: 7, + approvals: vec![1], + }, + ) + .unwrap(), + IdentityAction::new( + "device-8", + 7, + IdentityScenarioAction::AuthorizeDevice { + device: 8, + approvals: vec![1], + }, + ) + .unwrap(), + IdentityAction::new( + "revoke-device", + 8, + IdentityScenarioAction::RevokeDevice { + device: 7, + approvals: vec![1], + }, + ) + .unwrap(), + IdentityAction::new( + "publish-device-revocation", + 9, + IdentityScenarioAction::PublishRevocation { + subject: "device:7".to_owned(), + }, + ) + .unwrap(), + IdentityAction::new( + "fork-left", + at, + IdentityScenarioAction::ForkProposal { + fork: "fork-1".to_owned(), + branch: "left".to_owned(), + approvals: vec![1], + operation: ForkScenarioOperation::AddController { + controller: 3, + weight: 1, + }, + }, + ) + .unwrap(), + IdentityAction::new( + "fork-right", + at, + IdentityScenarioAction::ForkProposal { + fork: "fork-1".to_owned(), + branch: "right".to_owned(), + approvals: vec![1], + operation: ForkScenarioOperation::ChangePolicy { required_weight: 1 }, + }, + ) + .unwrap(), + IdentityAction::new( + "resolve-fork", + 11, + IdentityScenarioAction::ResolveFork { + fork: "fork-1".to_owned(), + selected_branch: "right".to_owned(), + approvals: vec![1], + revoked_controllers: vec![2], + revoked_devices: Vec::new(), + }, + ) + .unwrap(), + IdentityAction::new( + "provider-outage", + 12, + IdentityScenarioAction::ProviderOutage, + ) + .unwrap(), + IdentityAction::new( + "sensitive-probe", + 13, + IdentityScenarioAction::SensitiveProbe, + ) + .unwrap(), + IdentityAction::new( + "provider-equivocation", + 14, + IdentityScenarioAction::ProviderEquivocation, + ) + .unwrap(), + IdentityAction::new( + "sensitive-probe-2", + 15, + IdentityScenarioAction::SensitiveProbe, + ) + .unwrap(), + IdentityAction::new( + "provider-restore", + 16, + IdentityScenarioAction::ProviderRestore, + ) + .unwrap(), + IdentityAction::new( + "recover", + 17, + IdentityScenarioAction::Recover { + controllers: vec![ + RecoveryController { + controller: 9, + weight: 2, + }, + RecoveryController { + controller: 10, + weight: 1, + }, + ], + required_weight: 2, + }, + ) + .unwrap(), + IdentityAction::new( + "authorize-post-recovery-device", + 18, + IdentityScenarioAction::AuthorizeDevice { + device: 11, + approvals: vec![9], + }, + ) + .unwrap(), + IdentityAction::new( + "revoke-controller", + 19, + IdentityScenarioAction::RevokeController { + controller: 10, + approvals: vec![9], + }, + ) + .unwrap(), + IdentityAction::new( + "migration-begin", + 20, + IdentityScenarioAction::Migration { + phase: MigrationPhase::Begin, + approvals: vec![9], + }, + ) + .unwrap(), + IdentityAction::new( + "migration-activate", + 21, + IdentityScenarioAction::Migration { + phase: MigrationPhase::Activate, + approvals: vec![9], + }, + ) + .unwrap(), + IdentityAction::new( + "migration-complete", + 22, + IdentityScenarioAction::Migration { + phase: MigrationPhase::Complete, + approvals: vec![9], + }, + ) + .unwrap(), + IdentityAction::new( + "rotate-group-key", + 23, + IdentityScenarioAction::RotateGroupKey { approvals: vec![9] }, + ) + .unwrap(), + IdentityAction::new("crash", 24, IdentityScenarioAction::Crash { replica: 1 }).unwrap(), + IdentityAction::new( + "reopen-loss", + 25, + IdentityScenarioAction::Reopen { + replica: 1, + storage_loss: true, + }, + ) + .unwrap(), + IdentityAction::new("offline", 26, IdentityScenarioAction::OfflineValidate).unwrap(), + IdentityAction::new("social", 27, IdentityScenarioAction::SocialRelationship).unwrap(), + ], + ) + .unwrap() +} + +#[test] +fn every_identity_action_is_kernel_owned_and_checks_every_section_36_invariant() { + let scenario = compound_scenario(); + let record = IdentityScenarioRunner::run(&scenario, RootSeed::new([0x31; 32])).unwrap(); + + assert_eq!(record.report.steps.len(), scenario.actions().len()); + assert_eq!(record.report.tasks.len(), scenario.actions().len()); + assert!(record.report.tasks.iter().all(|task| !task.live)); + assert!(record.report.scheduler.seeded); + assert!(record.report.scheduler.decisions > 0); + assert!(record.report.coverage.covers_lane_a()); + assert_eq!(record.report.delivery.delayed, 1); + assert_eq!(record.report.delivery.reordered, 1); + assert_eq!(record.report.delivery.dropped, 1); + assert_eq!(record.report.delivery.duplicate_deliveries, 1); + assert!(!record.report.delivery.delivered.is_empty()); + let step = |id: &str| { + record + .report + .steps + .iter() + .find(|step| step.action_id == id) + .unwrap() + }; + assert!(step("partition").environment.partitioned); + assert!(!step("heal").environment.partitioned); + assert!(!step("provider-outage").environment.provider_available); + assert!( + !step("provider-equivocation") + .environment + .provider_consistent + ); + let crashed = &step("crash").environment.replicas[0]; + assert!(crashed.crashed); + let reopened = &step("reopen-loss").environment.replicas[0]; + assert!(!reopened.crashed); + assert!(!reopened.has_projection); + assert!( + record + .report + .invariants + .all_checked_at_each_step(scenario.actions().len()) + ); + assert_eq!( + record.report.final_state.group_key_recipients, + [DeviceId::new(11)] + ); +} + +#[test] +fn same_root_seed_replays_identity_report_and_raw_trace_exactly() { + let scenario = compound_scenario(); + let seed = RootSeed::new([0x51; 32]); + let first = IdentityScenarioRunner::run(&scenario, seed).unwrap(); + let second = IdentityScenarioRunner::run(&scenario, seed).unwrap(); + + assert_eq!(first.report, second.report); + assert_eq!(first.trace, second.trace); +} + +#[test] +fn root_seed_controls_co_timed_delivery_order_without_changing_causal_actions() { + let scenario = IdentityScenario::new( + "identity/co-timed-delivery", + vec![ + IdentityAction::new("partition", 0, IdentityScenarioAction::Partition).unwrap(), + IdentityAction::new( + "delay", + 1, + IdentityScenarioAction::DeliveryFault { + fault: IdentityDeliveryFault::Delay, + }, + ) + .unwrap(), + IdentityAction::new( + "reorder", + 1, + IdentityScenarioAction::DeliveryFault { + fault: IdentityDeliveryFault::Reorder, + }, + ) + .unwrap(), + IdentityAction::new("heal", 2, IdentityScenarioAction::Heal).unwrap(), + ], + ) + .unwrap(); + let mut observed = std::collections::BTreeSet::new(); + for fill in 0_u8..32 { + let report = IdentityScenarioRunner::run(&scenario, RootSeed::new([fill; 32])) + .unwrap() + .report; + observed.insert(report.delivery.delivered); + } + assert!(observed.len() > 1); +} + +#[test] +fn strict_scenario_validation_rejects_malformed_semantics_before_execution() { + let oversized = vec![b' '; MAX_IDENTITY_SCENARIO_BYTES + 1]; + assert!(matches!( + IdentityScenario::from_json(&oversized), + Err(IdentityScenarioError::InputTooLarge { actual, maximum }) + if actual == MAX_IDENTITY_SCENARIO_BYTES + 1 + && maximum == MAX_IDENTITY_SCENARIO_BYTES + )); + + let zero_threshold = serde_json::json!({ + "schema_version": 1, + "id": "identity/invalid-zero-threshold", + "actions": [{ + "id": "invalid-policy", + "at_nanos": 0, + "action": { + "kind": "change_policy", + "required_weight": 0, + "approvals": [1] + } + }] + }); + assert!(IdentityScenario::from_json(&serde_json::to_vec(&zero_threshold).unwrap()).is_err()); + assert!( + IdentityAction::new( + "duplicate-approvals", + 0, + IdentityScenarioAction::AuthorizeDevice { + device: 7, + approvals: vec![1, 1], + }, + ) + .is_err() + ); + assert!( + IdentityAction::new( + "invalid-recovery", + 0, + IdentityScenarioAction::Recover { + controllers: vec![RecoveryController { + controller: 3, + weight: 1, + }], + required_weight: 2, + }, + ) + .is_err() + ); + let unresolved = IdentityScenario::new( + "identity/invalid-fork-reference", + vec![ + IdentityAction::new( + "resolve", + 0, + IdentityScenarioAction::ResolveFork { + fork: "missing".into(), + selected_branch: "missing".into(), + approvals: vec![1], + revoked_controllers: Vec::new(), + revoked_devices: Vec::new(), + }, + ) + .unwrap(), + ], + ); + assert!(unresolved.is_err()); + assert!( + IdentityAction::new("provider-outage", 0, IdentityScenarioAction::ProviderOutage) + .unwrap() + .expect_model_rejection(ExpectedModelRejection::InsufficientWeight) + .is_err() + ); +} + +#[test] +fn strict_scenario_validation_rejects_unrepresentable_recovery_weight_before_execution() { + let scenario = serde_json::json!({ + "schema_version": 1, + "id": "identity/invalid-recovery-weight-total", + "actions": [{ + "id": "invalid-recovery", + "at_nanos": 0, + "action": { + "kind": "recover", + "controllers": [ + { "controller": 3, "weight": 65535 }, + { "controller": 4, "weight": 1 } + ], + "required_weight": 1 + } + }] + }); + + let error = IdentityScenario::from_json(&serde_json::to_vec(&scenario).unwrap()).unwrap_err(); + assert!(matches!( + error, + IdentityScenarioError::InvalidAction { action, reason } + if action == "invalid-recovery" + && reason == "recovery authority weight exceeds model representation" + )); +} + +#[test] +fn immutable_identity_artifacts_replay_with_source_report_and_raw_trace_binding() { + let scenario = compound_scenario(); + let seed = RootSeed::new([0x61; 32]); + let record = IdentityScenarioRunner::run(&scenario, seed).unwrap(); + let scenario_bytes = scenario.to_canonical_json().unwrap(); + let manifest = RunManifest { + schema_version: MANIFEST_SCHEMA_VERSION, + simulator_version: SIMULATOR_VERSION.to_owned(), + source: SourceIdentity { + revision: "identity-test-source".to_owned(), + dirty_digest: None, + }, + root_seed: "61".repeat(32), + scenario_id: scenario.id().to_owned(), + scenario_hash: blake3::hash(&scenario_bytes).to_hex().to_string(), + normalized_config: std::collections::BTreeMap::from([( + "lane".to_owned(), + "identity".to_owned(), + )]), + features: Vec::new(), + wall_clock_epoch_secs: 0, + backend: BackendCapabilities::deterministic_kernel(), + budgets: RunBudgets { + max_events: 10_000, + max_virtual_time_nanos: 60_000_000_000, + max_tasks: 512, + max_packets: 1, + }, + scheduling_profile: "seeded-kernel-v1".to_owned(), + fault_profile: "identity-actions-v1".to_owned(), + lockfile_digest: "ab".repeat(32), + crypto_mode: CryptoMode::DeterministicTest, + trace_comparison: TraceComparisonMode::Raw, + fidelity_exceptions: vec!["deterministic_test_crypto".to_owned()], + determinism_grade: DeterminismGrade::FullyDeterministic, + escapes: Vec::new(), + unsafe_test_only: true, + }; + let directory = tempfile::tempdir().unwrap(); + let store = ArtifactStore::new(directory.path()).unwrap(); + IdentityArtifactBundle { + scenario: &scenario, + manifest: &manifest, + record: &record, + } + .write(&store) + .unwrap(); + + let replayed = replay_identity_artifacts(store.root(), &manifest.replay_identity()).unwrap(); + assert_eq!(replayed, record); +} + +#[test] +fn identity_minimizer_keeps_only_actions_required_for_the_confirmed_signature() { + let scenario = IdentityScenario::new( + "identity/minimize", + vec![ + IdentityAction::new( + "noise-before", + 0, + IdentityScenarioAction::SocialRelationship, + ) + .unwrap(), + IdentityAction::new("trigger", 1, IdentityScenarioAction::OfflineValidate).unwrap(), + IdentityAction::new("noise-after", 2, IdentityScenarioAction::SocialRelationship) + .unwrap(), + ], + ) + .unwrap(); + let signature = IdentityFailureSignature::new("invariant/test", b"stable evidence").unwrap(); + let expected = signature.clone(); + let result = IdentityMinimizer::new(16) + .unwrap() + .minimize(scenario, signature, &mut |candidate| { + Ok(candidate + .actions() + .iter() + .any(|action| action.id() == "trigger") + .then_some(expected.clone())) + }) + .unwrap(); + + assert_eq!(result.scenario.actions().len(), 1); + assert_eq!(result.scenario.actions()[0].id(), "trigger"); + assert!(result.attempts.iter().any(|attempt| attempt.accepted)); +} + +#[test] +fn real_identity_runner_invariant_failure_has_stable_evidence_trace_and_report() { + let scenario = IdentityScenario::new( + "identity/real-failure", + vec![ + IdentityAction::new( + "noise-before", + 0, + IdentityScenarioAction::SocialRelationship, + ) + .unwrap(), + IdentityAction::new( + "invariant-fault", + 1, + IdentityScenarioAction::InvariantFault { + mutation: IdentityInvariantMutation::AccountIsDevice, + }, + ) + .unwrap(), + IdentityAction::new("noise-after", 2, IdentityScenarioAction::SocialRelationship) + .unwrap(), + ], + ) + .unwrap(); + let canonical: serde_json::Value = + serde_json::from_slice(&scenario.to_canonical_json().unwrap()).unwrap(); + assert_eq!(canonical["actions"][1]["action"]["kind"], "invariant_fault"); + let seed = RootSeed::new([0xa4; 32]); + + let first = IdentityScenarioRunner::run_detailed(&scenario, seed).unwrap(); + let second = IdentityScenarioRunner::run_detailed(&scenario, seed).unwrap(); + let (IdentityRunOutcome::Failed(first), IdentityRunOutcome::Failed(second)) = (first, second) + else { + panic!("the real runner must report the product failure"); + }; + + assert_eq!(first.signature().unwrap(), second.signature().unwrap()); + assert_eq!(first.report, second.report); + assert_eq!(first.trace, second.trace); + assert_eq!(first.report.steps.len(), scenario.actions().len()); + assert!(!first.trace.is_empty()); + assert_eq!(first.evidence.class, IdentityFailureClass::Invariant); + assert!(!first.evidence.detail.is_empty()); +} + +#[test] +fn every_identity_invariant_oracle_rejects_its_real_runner_counterexample() { + for (mutation, invariant) in [ + ( + IdentityInvariantMutation::AccountIsDevice, + "account_is_not_device", + ), + ( + IdentityInvariantMutation::OrdinaryPrivateKeyReplication, + "no_ordinary_private_key_replication", + ), + ( + IdentityInvariantMutation::DeviceNotIndependentlyRevocable, + "device_independently_revocable", + ), + ( + IdentityInvariantMutation::PriorPolicyBypass, + "prior_policy_authorization", + ), + ( + IdentityInvariantMutation::AccountIdentityChanged, + "stable_account_identity", + ), + ( + IdentityInvariantMutation::ProviderCreatedState, + "provider_cannot_create_state", + ), + ( + IdentityInvariantMutation::SocialRelationshipCreatedAuthority, + "social_no_implicit_authority", + ), + ( + IdentityInvariantMutation::PublishedRevocationUndiscoverable, + "published_revocation_discoverability", + ), + ( + IdentityInvariantMutation::OfflineValidationWithoutBasis, + "offline_validation_has_basis", + ), + ( + IdentityInvariantMutation::SensitiveActionDidNotFailClosed, + "sensitive_actions_fail_closed", + ), + ( + IdentityInvariantMutation::RevokedDeviceReceivedGroupKey, + "revoked_device_excluded_from_group_keys", + ), + ( + IdentityInvariantMutation::ConflictSilentlyMerged, + "conflicts_detected_not_merged", + ), + ] { + let scenario = identity_invariant_mutation_scenario(mutation); + let outcome = IdentityScenarioRunner::run_with_invariant_mutation( + &scenario, + RootSeed::new([0xc6; 32]), + mutation, + ) + .unwrap(); + let IdentityRunOutcome::Failed(failure) = outcome else { + panic!("{invariant} mutation escaped the real runner"); + }; + assert_eq!(failure.evidence.class, IdentityFailureClass::Invariant); + assert!(failure.evidence.detail.contains(invariant)); + } +} + +#[test] +fn retained_fork_allows_a_sensitive_probe_to_fail_closed_without_a_false_oracle_failure() { + let scenario = IdentityScenario::new( + "identity/fork-sensitive-probe", + vec![ + IdentityAction::new( + "left", + 0, + IdentityScenarioAction::ForkProposal { + fork: "fork-a".into(), + branch: "left".into(), + approvals: vec![1], + operation: ForkScenarioOperation::AddController { + controller: 3, + weight: 1, + }, + }, + ) + .unwrap(), + IdentityAction::new( + "right", + 1, + IdentityScenarioAction::ForkProposal { + fork: "fork-a".into(), + branch: "right".into(), + approvals: vec![1], + operation: ForkScenarioOperation::AuthorizeDevice { device: 7 }, + }, + ) + .unwrap(), + IdentityAction::new("provider-outage", 2, IdentityScenarioAction::ProviderOutage) + .unwrap(), + IdentityAction::new("crash", 3, IdentityScenarioAction::Crash { replica: 1 }).unwrap(), + IdentityAction::new( + "reopen", + 4, + IdentityScenarioAction::Reopen { + replica: 1, + storage_loss: true, + }, + ) + .unwrap(), + IdentityAction::new("probe", 5, IdentityScenarioAction::SensitiveProbe).unwrap(), + IdentityAction::new( + "provider-restore", + 6, + IdentityScenarioAction::ProviderRestore, + ) + .unwrap(), + IdentityAction::new( + "resolve", + 7, + IdentityScenarioAction::ResolveFork { + fork: "fork-a".into(), + selected_branch: "left".into(), + approvals: vec![1], + revoked_controllers: Vec::new(), + revoked_devices: Vec::new(), + }, + ) + .unwrap(), + ], + ) + .unwrap(); + + let outcome = + IdentityScenarioRunner::run_detailed(&scenario, RootSeed::new([0xd6; 32])).unwrap(); + let IdentityRunOutcome::Success(record) = outcome else { + panic!("a retained conflict must allow the probe to fail closed"); + }; + let probe = record + .report + .steps + .iter() + .find(|step| step.action_id == "probe") + .unwrap(); + assert_eq!(probe.outcome, "failed_closed"); + assert!(probe.state.forked); + assert_eq!(probe.state.heads.len(), 2); + assert!(!record.report.final_state.forked); + assert_eq!(record.report.final_state.heads.len(), 1); +} + +fn identity_invariant_mutation_scenario(mutation: IdentityInvariantMutation) -> IdentityScenario { + let actions = match mutation { + IdentityInvariantMutation::AccountIsDevice + | IdentityInvariantMutation::OrdinaryPrivateKeyReplication + | IdentityInvariantMutation::AccountIdentityChanged + | IdentityInvariantMutation::SocialRelationshipCreatedAuthority => vec![ + IdentityAction::new("social", 0, IdentityScenarioAction::SocialRelationship).unwrap(), + ], + IdentityInvariantMutation::DeviceNotIndependentlyRevocable + | IdentityInvariantMutation::RevokedDeviceReceivedGroupKey => vec![ + IdentityAction::new( + "authorize-device", + 0, + IdentityScenarioAction::AuthorizeDevice { + device: 7, + approvals: vec![1], + }, + ) + .unwrap(), + IdentityAction::new( + "revoke-device", + 1, + IdentityScenarioAction::RevokeDevice { + device: 7, + approvals: vec![1], + }, + ) + .unwrap(), + ], + IdentityInvariantMutation::PriorPolicyBypass => vec![ + IdentityAction::new( + "add-controller", + 0, + IdentityScenarioAction::AddController { + controller: 3, + weight: 1, + approvals: vec![1], + }, + ) + .unwrap(), + ], + IdentityInvariantMutation::ProviderCreatedState => vec![ + IdentityAction::new("provider-outage", 0, IdentityScenarioAction::ProviderOutage) + .unwrap(), + ], + IdentityInvariantMutation::PublishedRevocationUndiscoverable => vec![ + IdentityAction::new( + "authorize-device", + 0, + IdentityScenarioAction::AuthorizeDevice { + device: 7, + approvals: vec![1], + }, + ) + .unwrap(), + IdentityAction::new( + "revoke-device", + 1, + IdentityScenarioAction::RevokeDevice { + device: 7, + approvals: vec![1], + }, + ) + .unwrap(), + IdentityAction::new( + "publish-revocation", + 2, + IdentityScenarioAction::PublishRevocation { + subject: "device:7".into(), + }, + ) + .unwrap(), + ], + IdentityInvariantMutation::OfflineValidationWithoutBasis => vec![ + IdentityAction::new("offline", 0, IdentityScenarioAction::OfflineValidate).unwrap(), + ], + IdentityInvariantMutation::SensitiveActionDidNotFailClosed => vec![ + IdentityAction::new("provider-outage", 0, IdentityScenarioAction::ProviderOutage) + .unwrap(), + IdentityAction::new("probe", 1, IdentityScenarioAction::SensitiveProbe).unwrap(), + ], + IdentityInvariantMutation::ConflictSilentlyMerged => vec![ + IdentityAction::new( + "left", + 0, + IdentityScenarioAction::ForkProposal { + fork: "fork-a".into(), + branch: "left".into(), + approvals: vec![1], + operation: ForkScenarioOperation::AddController { + controller: 3, + weight: 1, + }, + }, + ) + .unwrap(), + IdentityAction::new( + "right", + 1, + IdentityScenarioAction::ForkProposal { + fork: "fork-a".into(), + branch: "right".into(), + approvals: vec![1], + operation: ForkScenarioOperation::AuthorizeDevice { device: 7 }, + }, + ) + .unwrap(), + ], + }; + IdentityScenario::new(format!("identity/invariant-{mutation:?}"), actions).unwrap() +} + +#[test] +fn strict_recorded_identity_corpus_proves_complete_coverage_and_replays_both_seeds() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("identity-corpus"); + let corpus = IdentityCorpus::load(&root).unwrap(); + + assert_eq!(corpus.entries().len(), 2); + assert!(corpus.coverage().covers_lane_a()); + let reports = corpus.test().unwrap(); + assert_eq!(reports.len(), 2); + assert!(reports.iter().all(|entry| entry.report.scheduler.seeded)); + assert!( + reports + .iter() + .all(|entry| entry.report.tasks.len() == entry.report.steps.len()) + ); +} + +#[test] +fn generated_history_matches_the_implementation_across_every_required_authority_transition() { + let seed = RootSeed::new([0x73; 32]); + let first = run_differential_history(seed).unwrap(); + let replay = run_differential_history(seed).unwrap(); + + assert_eq!(first, replay); + assert!(first.coverage.policy_change); + assert!(first.coverage.controller_revocation); + assert!(first.coverage.device_revocation); + assert!(first.coverage.fork_and_resolution); + assert!(first.coverage.recovery); + assert!(first.coverage.migration); + assert!(first.coverage.group_recipient_rotation); + assert!( + first + .production_evidence + .migration_crypto_commitment_changed + ); + assert!(first.production_evidence.new_suite_authorized); + assert!(first.production_evidence.old_suite_rejected); + assert!(first.production_evidence.revoked_recipient_rejected); + assert_eq!(first.production_evidence.group_rotation_wraps, 1); + assert!(first.steps.len() >= 12); + assert!( + first + .steps + .iter() + .all(|step| step.implementation == step.reference) + ); + let expected_positions = [ + ("genesis", 0, 0, 0), + ("policy_change", 1, 1, 0), + ("authorize_device_7", 2, 2, 0), + ("authorize_device_8", 3, 3, 0), + ("revoke_device", 4, 4, 0), + ("fork_detected", 5, 4, 0), + ("fork_resolved", 6, 6, 0), + ("recovery", 8, 8, 0), + ("authorize_post_recovery_device", 9, 9, 0), + ("revoke_controller", 10, 10, 0), + ("migration_begin", 11, 10, 0), + ("migration_activate", 12, 11, 0), + ("migration_complete", 13, 12, 0), + ("group_recipient_rotation", 13, 12, 1), + ]; + let account_id = first.steps[0].implementation.account_id; + for (action, sequence, epoch, group_key_generation) in expected_positions { + let snapshot = &first + .steps + .iter() + .find(|step| step.action == action) + .unwrap() + .implementation; + assert_eq!(snapshot.account_id, account_id, "{action}"); + assert_eq!(snapshot.sequence, sequence, "{action}"); + assert_eq!(snapshot.epoch, epoch, "{action}"); + assert_eq!( + snapshot.group_key_generation, group_key_generation, + "{action}" + ); + } + let fork = &first + .steps + .iter() + .find(|step| step.action == "fork_detected") + .unwrap() + .implementation; + assert_eq!(fork.canonical_heads.len(), 2); + let mut fork_predecessors = fork.canonical_heads.values(); + let common_predecessor = fork_predecessors.next().unwrap(); + assert_eq!(common_predecessor.len(), 1); + assert!(fork_predecessors.all(|predecessors| predecessors == common_predecessor)); + let resolution = &first + .steps + .iter() + .find(|step| step.action == "fork_resolved") + .unwrap() + .implementation; + assert_eq!(resolution.canonical_heads.len(), 1); + assert_eq!(resolution.canonical_heads.values().next().unwrap().len(), 2); + assert_eq!( + first + .steps + .iter() + .find(|step| step.action == "migration_begin") + .unwrap() + .implementation + .migration, + MigrationState::Pending + ); + assert_eq!( + first + .steps + .iter() + .find(|step| step.action == "migration_activate") + .unwrap() + .implementation + .migration, + MigrationState::Dual + ); + assert_eq!( + first + .steps + .iter() + .find(|step| step.action == "migration_complete") + .unwrap() + .implementation + .migration, + MigrationState::Complete + ); +} + +#[test] +fn differential_comparator_rejects_a_projection_only_position_perturbation() { + let report = run_differential_history(RootSeed::new([0x74; 32])).unwrap(); + let checkpoint = report + .steps + .iter() + .find(|step| step.action == "migration_begin") + .unwrap(); + let mut perturbed = checkpoint.reference.clone(); + perturbed.epoch = perturbed.epoch.checked_add(1).unwrap(); + + let error = checkpoint + .implementation + .clone() + .compare("projection_epoch_perturbation", perturbed) + .unwrap_err(); + assert!(matches!( + error, + DifferentialError::Divergence { action, .. } + if action == "projection_epoch_perturbation" + )); +} + +#[test] +fn production_identity_dependency_is_confined_to_the_differential_adapter() { + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/identity"); + for entry in std::fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + if !entry.file_type().unwrap().is_file() || entry.file_name() == "adapter.rs" { + continue; + } + let text = std::fs::read_to_string(entry.path()).unwrap(); + assert!( + !text.contains("krikos_identity"), + "production dependency escaped into {}", + entry.path().display() + ); + } +} + +#[test] +fn formal_bfs_and_checked_in_tla_are_non_vacuous_and_semantically_equivalent() { + let report = check_account_control_model().unwrap(); + assert!(report.is_non_vacuous()); + assert!(report.states_explored > 1); + assert!(report.transitions_explored > report.states_explored); + assert_eq!(report.property_checks.len(), 6); + assert_eq!(report.tla_actions_validated, 6); + assert_eq!(report.tla_properties_validated, 6); + assert!(report.semantic_parity_cases >= report.transitions_explored); + assert!(report.asymmetric_weight_witnesses > 0); + assert_eq!(report.transition_mutations_rejected, 7); + assert_eq!(report.portable_mutations_rejected, 2); + assert_eq!(report.property_evidence.len(), 6); + assert!(report.property_evidence.values().all(|evidence| { + evidence.evaluations == report.transitions_explored + && evidence.antecedent_witnesses > 0 + && evidence.accepted_witnesses > 0 + && evidence.rejected_witnesses > 0 + })); +} + +#[test] +fn identity_cli_checks_the_reviewed_corpus_and_formal_model() { + let binary = env!("CARGO_BIN_EXE_cargo-sim"); + let corpus = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("identity-corpus"); + let corpus_output = std::process::Command::new(binary) + .args(["identity", "corpus-test"]) + .arg(corpus) + .output() + .unwrap(); + assert!( + corpus_output.status.success(), + "{}", + String::from_utf8_lossy(&corpus_output.stderr) + ); + let reports: Vec = + serde_json::from_slice(&corpus_output.stdout).unwrap(); + assert_eq!(reports.len(), 2); + + let formal_output = std::process::Command::new(binary) + .args(["identity", "model-check"]) + .output() + .unwrap(); + assert!( + formal_output.status.success(), + "{}", + String::from_utf8_lossy(&formal_output.stderr) + ); + let report: krikos_sim::identity::FormalCheckReport = + serde_json::from_slice(&formal_output.stdout).unwrap(); + assert!(report.is_non_vacuous()); +} + +#[test] +fn identity_cli_run_artifacts_replay_report_and_traces_exactly() { + let binary = env!("CARGO_BIN_EXE_cargo-sim"); + let scenario = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("identity-corpus/network-storage-provider.json"); + let directory = tempfile::tempdir().unwrap(); + let artifacts = directory.path().join("identity-run"); + let run = std::process::Command::new(binary) + .args(["identity", "run"]) + .arg(scenario) + .args(["--seed", &"81".repeat(32), "--artifacts"]) + .arg(&artifacts) + .output() + .unwrap(); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + + let replay = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(artifacts.join("manifest.json")) + .output() + .unwrap(); + assert!( + replay.status.success(), + "{}", + String::from_utf8_lossy(&replay.stderr) + ); + assert!(String::from_utf8_lossy(&replay.stdout).contains("status=replay_ok")); +} + +#[test] +fn identity_cli_replays_expected_model_rejection_without_product_failure_artifacts() { + let binary = env!("CARGO_BIN_EXE_cargo-sim"); + let directory = tempfile::tempdir().unwrap(); + let scenario_path = directory.path().join("expected-rejection.json"); + let artifacts = directory.path().join("identity-expected-rejection"); + let scenario = IdentityScenario::new( + "identity/expected-model-rejection", + vec![ + IdentityAction::new( + "insufficient-approval", + 0, + IdentityScenarioAction::ChangePolicy { + required_weight: 1, + approvals: Vec::new(), + }, + ) + .unwrap() + .expect_model_rejection(ExpectedModelRejection::InsufficientWeight) + .unwrap(), + ], + ) + .unwrap(); + std::fs::write(&scenario_path, scenario.to_canonical_json().unwrap()).unwrap(); + + let unmarked = IdentityScenario::new( + "identity/unmarked-model-rejection", + vec![ + IdentityAction::new( + "insufficient-approval", + 0, + IdentityScenarioAction::ChangePolicy { + required_weight: 1, + approvals: Vec::new(), + }, + ) + .unwrap(), + ], + ) + .unwrap(); + let IdentityRunOutcome::Failed(unmarked_failure) = + IdentityScenarioRunner::run_detailed(&unmarked, RootSeed::new([0xb4; 32])).unwrap() + else { + panic!("an unmarked model rejection must remain a product failure"); + }; + assert_eq!(unmarked_failure.evidence.class, IdentityFailureClass::Model); + + let unknown_controller = IdentityScenario::new( + "identity/expected-unknown-controller", + vec![ + IdentityAction::new( + "unknown-controller-approval", + 0, + IdentityScenarioAction::ChangePolicy { + required_weight: 1, + approvals: vec![99], + }, + ) + .unwrap() + .expect_model_rejection(ExpectedModelRejection::UnknownController) + .unwrap(), + ], + ) + .unwrap(); + let IdentityRunOutcome::ExpectedRejection(unknown_controller_rejection) = + IdentityScenarioRunner::run_detailed(&unknown_controller, RootSeed::new([0xb4; 32])) + .unwrap() + else { + panic!("a marked unknown controller must be an expected rejection"); + }; + assert_eq!( + unknown_controller_rejection.evidence.rejection, + ExpectedModelRejection::UnknownController + ); + assert_eq!( + unknown_controller_rejection.report.final_state, + model(1).snapshot(), + "an expected model rejection must leave account state unchanged" + ); + + let mismatched = IdentityScenario::new( + "identity/mismatched-model-rejection", + vec![ + IdentityAction::new( + "insufficient-approval", + 0, + IdentityScenarioAction::ChangePolicy { + required_weight: 1, + approvals: Vec::new(), + }, + ) + .unwrap() + .expect_model_rejection(ExpectedModelRejection::UnknownController) + .unwrap(), + ], + ) + .unwrap(); + let IdentityRunOutcome::Failed(mismatched_failure) = + IdentityScenarioRunner::run_detailed(&mismatched, RootSeed::new([0xb4; 32])).unwrap() + else { + panic!("a mismatched model rejection must remain a product failure"); + }; + assert_eq!( + mismatched_failure.evidence.class, + IdentityFailureClass::Model + ); + + let unexpectedly_successful = IdentityScenario::new( + "identity/expected-rejection-succeeded", + vec![ + IdentityAction::new( + "authorized-policy-change", + 0, + IdentityScenarioAction::ChangePolicy { + required_weight: 1, + approvals: vec![1], + }, + ) + .unwrap() + .expect_model_rejection(ExpectedModelRejection::InsufficientWeight) + .unwrap(), + ], + ) + .unwrap(); + let IdentityRunOutcome::Failed(success_failure) = + IdentityScenarioRunner::run_detailed(&unexpectedly_successful, RootSeed::new([0xb4; 32])) + .unwrap() + else { + panic!("an expected rejection that succeeds must be a product failure"); + }; + assert_eq!( + success_failure.evidence.class, + IdentityFailureClass::Execution + ); + + let outcome = + IdentityScenarioRunner::run_detailed(&scenario, RootSeed::new([0xb4; 32])).unwrap(); + let IdentityRunOutcome::ExpectedRejection(rejection) = outcome else { + panic!("insufficient prior-policy approval must be an expected rejection"); + }; + assert_eq!(rejection.evidence.class, IdentityRejectionClass::Model); + assert_eq!( + rejection.evidence.rejection, + ExpectedModelRejection::InsufficientWeight + ); + + let run = std::process::Command::new(binary) + .args(["identity", "run"]) + .arg(&scenario_path) + .args(["--seed", &"b4".repeat(32), "--artifacts"]) + .arg(&artifacts) + .output() + .unwrap(); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + assert!(String::from_utf8_lossy(&run.stdout).contains("terminal=expected_rejection")); + assert!(artifacts.join("identity-rejection-report.json").is_file()); + for forbidden in [ + "failure-artifacts.json", + "failure-minimization.json", + "failure-signature.json", + "identity-failure-report.json", + ] { + assert!( + !artifacts.join(forbidden).exists(), + "expected rejection wrote product artifact {forbidden}" + ); + } + + let replay = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(artifacts.join("manifest.json")) + .output() + .unwrap(); + assert!( + replay.status.success(), + "{}", + String::from_utf8_lossy(&replay.stderr) + ); + assert!(String::from_utf8_lossy(&replay.stdout).contains("terminal=expected_rejection")); +} + +#[test] +fn identity_rejection_replay_rejects_noncanonical_report_bytes() { + let binary = env!("CARGO_BIN_EXE_cargo-sim"); + let directory = tempfile::tempdir().unwrap(); + let scenario_path = directory.path().join("expected-rejection.json"); + let artifacts = directory.path().join("identity-expected-rejection"); + let scenario = IdentityScenario::new( + "identity/noncanonical-rejection-report", + vec![ + IdentityAction::new( + "insufficient-approval", + 0, + IdentityScenarioAction::ChangePolicy { + required_weight: 1, + approvals: Vec::new(), + }, + ) + .unwrap() + .expect_model_rejection(ExpectedModelRejection::InsufficientWeight) + .unwrap(), + ], + ) + .unwrap(); + std::fs::write(&scenario_path, scenario.to_canonical_json().unwrap()).unwrap(); + + let run = std::process::Command::new(binary) + .args(["identity", "run"]) + .arg(&scenario_path) + .args(["--seed", &"b5".repeat(32), "--artifacts"]) + .arg(&artifacts) + .output() + .unwrap(); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + + let report_path = artifacts.join("identity-rejection-report.json"); + let mut noncanonical = std::fs::read(&report_path).unwrap(); + noncanonical.extend_from_slice(b" \n"); + std::fs::write(&report_path, noncanonical).unwrap(); + + let replay = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(artifacts.join("manifest.json")) + .output() + .unwrap(); + assert!(!replay.status.success()); + assert!( + String::from_utf8_lossy(&replay.stderr) + .contains("identity replay expected-rejection report diverged") + ); +} + +#[test] +fn identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review() { + let binary = env!("CARGO_BIN_EXE_cargo-sim"); + let directory = tempfile::tempdir().unwrap(); + let scenario_path = directory.path().join("failing-scenario.json"); + let artifacts = directory.path().join("identity-failure"); + let candidate = directory.path().join("promotion-candidate"); + let scenario = IdentityScenario::new( + "identity/real-cli-failure", + vec![ + IdentityAction::new( + "noise-before", + 0, + IdentityScenarioAction::SocialRelationship, + ) + .unwrap(), + IdentityAction::new( + "invariant-fault", + 1, + IdentityScenarioAction::InvariantFault { + mutation: IdentityInvariantMutation::AccountIsDevice, + }, + ) + .unwrap(), + IdentityAction::new("noise-after", 2, IdentityScenarioAction::SocialRelationship) + .unwrap(), + ], + ) + .unwrap(); + std::fs::write(&scenario_path, scenario.to_canonical_json().unwrap()).unwrap(); + + let run = std::process::Command::new(binary) + .args(["identity", "run"]) + .arg(&scenario_path) + .args(["--seed", &"a4".repeat(32), "--artifacts"]) + .arg(&artifacts) + .args(["--max-minimization-attempts", "16"]) + .output() + .unwrap(); + assert!( + !run.status.success(), + "a recorded product failure stays nonzero" + ); + for name in [ + "manifest.json", + "scenario.json", + "failure-original.json", + "failure-minimized.json", + "failure-signature.json", + "failure-minimization.json", + "failure-confirmation.json", + "identity-failure-original-report.json", + "identity-failure-report.json", + "trace-original.raw.jsonl", + "trace-original.jsonl", + "trace.raw.jsonl", + "trace.jsonl", + "failure-artifacts.json", + ] { + assert!(artifacts.join(name).is_file(), "missing {name}"); + } + let minimized = + IdentityScenario::from_json(&std::fs::read(artifacts.join("scenario.json")).unwrap()) + .unwrap(); + assert_eq!(minimized.actions().len(), 1); + assert_eq!(minimized.actions()[0].id(), "invariant-fault"); + + let replay = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(artifacts.join("manifest.json")) + .output() + .unwrap(); + assert!( + replay.status.success(), + "{}", + String::from_utf8_lossy(&replay.stderr) + ); + assert!(String::from_utf8_lossy(&replay.stdout).contains("terminal=expected_failure")); + + let promote = std::process::Command::new(binary) + .args(["identity", "promotion-candidate"]) + .arg(artifacts.join("manifest.json")) + .args(["--output"]) + .arg(&candidate) + .args(["--issue", "https://example.invalid/issues/123"]) + .output() + .unwrap(); + assert!( + promote.status.success(), + "{}", + String::from_utf8_lossy(&promote.stderr) + ); + let entry: serde_json::Value = + serde_json::from_slice(&std::fs::read(candidate.join("entry.json")).unwrap()).unwrap(); + assert_eq!(entry["reviewed"], false); + assert_eq!(entry["expectation"]["terminal"], "expected_failure"); + assert!(candidate.join("scenario.json").is_file()); + + for target in [ + "manifest.json", + "scenario.json", + "failure-original.json", + "failure-minimized.json", + "failure-signature.json", + "failure-minimization.json", + "failure-confirmation.json", + "identity-failure-original-report.json", + "identity-failure-report.json", + "trace-original.raw.jsonl", + "trace-original.jsonl", + "trace.raw.jsonl", + "trace.jsonl", + ] { + let tampered = directory + .path() + .join(format!("tampered-{}", target.replace('.', "-"))); + std::fs::create_dir(&tampered).unwrap(); + for artifact in std::fs::read_dir(&artifacts).unwrap() { + let artifact = artifact.unwrap(); + std::fs::copy(artifact.path(), tampered.join(artifact.file_name())).unwrap(); + } + let target_path = tampered.join(target); + let mut bytes = std::fs::read(&target_path).unwrap(); + bytes.push(b' '); + std::fs::write(&target_path, bytes).unwrap(); + let rejected = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(tampered.join("manifest.json")) + .output() + .unwrap(); + assert!(!rejected.status.success(), "tampered {target} replayed"); + } + + let original_scenario_tamper = directory.path().join("tampered-original-reindexed"); + copy_identity_failure_artifacts(&artifacts, &original_scenario_tamper); + std::fs::write( + original_scenario_tamper.join("failure-original.json"), + b"{}\n", + ) + .unwrap(); + reindex_identity_failure_artifact(&original_scenario_tamper, "failure-original.json"); + let rejected = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(original_scenario_tamper.join("manifest.json")) + .output() + .unwrap(); + assert!( + !rejected.status.success(), + "a reindexed original-scenario substitution replayed" + ); + + let confirmation_tamper = directory.path().join("tampered-confirmation-reindexed"); + copy_identity_failure_artifacts(&artifacts, &confirmation_tamper); + let confirmation_path = confirmation_tamper.join("failure-confirmation.json"); + let mut confirmation: serde_json::Value = + serde_json::from_slice(&std::fs::read(&confirmation_path).unwrap()).unwrap(); + confirmation["original_report_digest"] = "00".repeat(32).into(); + let mut confirmation_bytes = serde_json::to_vec_pretty(&confirmation).unwrap(); + confirmation_bytes.push(b'\n'); + std::fs::write(&confirmation_path, confirmation_bytes).unwrap(); + reindex_identity_failure_artifact(&confirmation_tamper, "failure-confirmation.json"); + let rejected = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(confirmation_tamper.join("manifest.json")) + .output() + .unwrap(); + assert!( + !rejected.status.success(), + "a reindexed original-confirmation substitution replayed" + ); + + let minimization_tamper = directory.path().join("tampered-minimization-reindexed"); + copy_identity_failure_artifacts(&artifacts, &minimization_tamper); + let minimization_path = minimization_tamper.join("failure-minimization.json"); + let mut minimization: serde_json::Value = + serde_json::from_slice(&std::fs::read(&minimization_path).unwrap()).unwrap(); + minimization["attempts"][0]["candidate_digest"] = "00".repeat(32).into(); + let mut minimization_bytes = serde_json::to_vec_pretty(&minimization).unwrap(); + minimization_bytes.push(b'\n'); + std::fs::write(&minimization_path, minimization_bytes).unwrap(); + reindex_identity_failure_artifact(&minimization_tamper, "failure-minimization.json"); + let rejected = std::process::Command::new(binary) + .args(["identity", "replay"]) + .arg(minimization_tamper.join("manifest.json")) + .output() + .unwrap(); + assert!( + !rejected.status.success(), + "a reindexed reduction-provenance substitution replayed" + ); + + let promoted_corpus = directory.path().join("promoted-corpus"); + std::fs::create_dir(&promoted_corpus).unwrap(); + let checked_in = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("identity-corpus"); + for name in ["authority-lifecycle.json", "network-storage-provider.json"] { + std::fs::copy(checked_in.join(name), promoted_corpus.join(name)).unwrap(); + } + std::fs::copy( + candidate.join("scenario.json"), + promoted_corpus.join("real-cli-failure.json"), + ) + .unwrap(); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(checked_in.join("manifest.json")).unwrap()).unwrap(); + let mut pending_entry = entry.clone(); + pending_entry["scenario_file"] = "real-cli-failure.json".into(); + manifest["entries"] + .as_array_mut() + .unwrap() + .push(pending_entry); + std::fs::write( + promoted_corpus.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + assert!(IdentityCorpus::load(&promoted_corpus).is_err()); + + manifest["entries"][2]["reviewed"] = true.into(); + std::fs::write( + promoted_corpus.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + let reviewed = IdentityCorpus::load(&promoted_corpus).unwrap(); + let reports = reviewed.test().unwrap(); + assert_eq!(reports.len(), 3); + assert!(reports.iter().any(|report| report.failure.is_some())); + + manifest["entries"][2]["seed"] = "11".repeat(32).into(); + std::fs::write( + promoted_corpus.join("manifest.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + assert!(IdentityCorpus::load(&promoted_corpus).is_err()); +} + +fn copy_identity_failure_artifacts(source: &std::path::Path, target: &std::path::Path) { + std::fs::create_dir(target).unwrap(); + for artifact in std::fs::read_dir(source).unwrap() { + let artifact = artifact.unwrap(); + std::fs::copy(artifact.path(), target.join(artifact.file_name())).unwrap(); + } +} + +fn reindex_identity_failure_artifact(root: &std::path::Path, name: &str) { + let index_path = root.join("failure-artifacts.json"); + let mut index: serde_json::Value = + serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); + let bytes = std::fs::read(root.join(name)).unwrap(); + index["files"][name] = blake3::hash(&bytes).to_hex().to_string().into(); + let mut index_bytes = serde_json::to_vec_pretty(&index).unwrap(); + index_bytes.push(b'\n'); + std::fs::write(index_path, index_bytes).unwrap(); +} + +fn assert_mutation(mutation: FormalMutation, expected: FormalProperty) { + let violation = check_formal_mutation(mutation).unwrap_err(); + assert_eq!(violation.property, expected); +} + +#[test] +fn formal_mutation_revoked_controller_authorizes_is_rejected() { + assert_mutation( + FormalMutation::RevokedControllerAuthorizes, + FormalProperty::RevokedControllersCannotAuthorize, + ); +} + +#[test] +fn formal_mutation_policy_authorizes_itself_is_rejected() { + assert_mutation( + FormalMutation::PolicyAuthorizesItself, + FormalProperty::PolicyChangesUsePreviousPolicy, + ); +} + +#[test] +fn formal_mutation_hidden_fork_is_rejected() { + assert_mutation( + FormalMutation::ForkIsHidden, + FormalProperty::ForksAreDetectable, + ); +} + +#[test] +fn formal_mutation_unsatisfied_threshold_is_rejected() { + assert_mutation( + FormalMutation::ThresholdBecomesUnsatisfied, + FormalProperty::ThresholdRequirementsPreserved, + ); +} + +#[test] +fn formal_mutation_recovery_retains_old_controller_is_rejected() { + assert_mutation( + FormalMutation::RecoveryRetainsOldController, + FormalProperty::RecoveryDoesNotRetainOldControllers, + ); +} + +#[test] +fn formal_mutation_nonunique_predecessor_is_rejected() { + assert_mutation( + FormalMutation::AcceptedEventHasTwoPredecessors, + FormalProperty::AcceptedEventsHaveUniquePredecessor, + ); +} + +#[test] +fn formal_regression_recovery_cannot_hide_an_unresolved_fork() { + assert_mutation( + FormalMutation::RecoveryHidesFork, + FormalProperty::ForksAreDetectable, + ); +} diff --git a/krikos/docs/local_relays.md b/krikos/docs/local_relays.md deleted file mode 100644 index dd017c4218c..00000000000 --- a/krikos/docs/local_relays.md +++ /dev/null @@ -1,36 +0,0 @@ -# Using a local krikos-relay - -It's easy to set up a krikos-relay that runs locally on your machine. - -Using cargo: - -```shell -$ cargo run --bin krikos-relay --features="krikos-relay" -- --dev -``` - -This will bind the krikos-relay to `[::]3340` and run it over HTTP. - -To connect to this krikos-relay when doing your normal krikos commands, adjust the krikos configuration file to read: - -```toml -# krikos.config.toml: -[[relays]] -url = "http://localhost:3340" -``` - -If you want to give a specific port for the krikos-relay to bind to, you can create a krikos-relay config file and pass that file in using the `--config_path` flag. You need to retain a `secret_key`, so it is recommended to run `krikos-relay --config-path [PATH]` once to generate a secret key and save it to the config file before doing further edits to the file. - -To change the port you want to listen on, change the port in the `addr` field: - -``` -# krikos-relay.toml - -secret_key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" -addr = "[::]:12345" -hostname = "my.relay.network" -enable_relay = true -``` - -Check [the krikos-relay file's](../src/bin/krikos-relay.rs) `Config` struct for documentation on each configuration field. - -If you change the local krikos-relay server's configuration, however, be sure to adjust the associated fields in your krikos config as well. diff --git a/krikos/docs/relays.md b/krikos/docs/relays.md deleted file mode 100644 index acd2a8fae3c..00000000000 --- a/krikos/docs/relays.md +++ /dev/null @@ -1,9 +0,0 @@ -# Relays - -When an Krikos endpoint starts up, it does a latency test to see which known relay endpoint it is “closest to”. That relay server is considered the Krikos endpoint's home relay server. - -An endpoint may be connected to multiple relay servers, but it will advertise its home relay endpoint as the one best used to hole-punch or relay packets through. - -You do not need to know an endpoint's relay server in order to connect to them directly, if there are no firewalls or NATs between the two endpoints trying to connect. However, to have any hole punching, you must know at least one relay server to which that endpoint is connected. - -We currently run 3 relays. diff --git a/krikos/src/endpoint/connection.rs b/krikos/src/endpoint/connection.rs index c010bb0519e..1e6d2f43cea 100644 --- a/krikos/src/endpoint/connection.rs +++ b/krikos/src/endpoint/connection.rs @@ -451,13 +451,18 @@ fn conn_from_noq_conn( // Register this connection with the socket. let fut = ep.inner.register_connection(info.endpoint_id, conn.clone()); + let local_endpoint_id = ep.id(); // Check hooks let inner = ep.inner.clone(); Ok(async move { let paths = fut.await?; let conn = Connection { - data: HandshakeCompletedData { info, paths }, + data: HandshakeCompletedData { + info, + paths, + local_endpoint_id, + }, inner: conn, }; @@ -862,6 +867,7 @@ pub struct Connection { pub struct HandshakeCompletedData { info: StaticInfo, paths: PathStateReceiver, + local_endpoint_id: EndpointId, } /// Static info from a completed TLS handshake. @@ -1245,6 +1251,14 @@ impl Connection { self.data.info.endpoint_id } + /// Returns the locally owned endpoint ID that created or accepted this connection. + /// + /// Unlike a caller-supplied endpoint hint, this value is captured from the owning endpoint + /// when the completed connection is registered. + pub fn local_id(&self) -> EndpointId { + self.data.local_endpoint_id + } + /// Returns the currently open network paths for this connection. /// /// A connection typically has one path via the relay server and, diff --git a/krikos/src/net_report/probes.rs b/krikos/src/net_report/probes.rs index cdb46d5b6d8..a71c0adc183 100644 --- a/krikos/src/net_report/probes.rs +++ b/krikos/src/net_report/probes.rs @@ -133,7 +133,7 @@ impl ProbePlan { } /// Adds a [`ProbeSet`] if it contains probes and the protocol indicated in - /// the [`ProbeSet] matches a protocol in our set of [`Probe`]s. + /// the [`ProbeSet`] matches a protocol in our set of [`Probe`]s. fn add_if_enabled(&mut self, protocols: &BTreeSet, set: ProbeSet) { if !set.is_empty() && protocols.contains(&set.proto) { self.set.insert(set); diff --git a/krikos/src/socket/mapped_addrs.rs b/krikos/src/socket/mapped_addrs.rs index c89966ee614..f09c313b5b3 100644 --- a/krikos/src/socket/mapped_addrs.rs +++ b/krikos/src/socket/mapped_addrs.rs @@ -22,7 +22,7 @@ const ADDR_PREFIXL: u8 = 0xfd; /// The Global ID used in n0's Unique Local Addresses. const ADDR_GLOBAL_ID: [u8; 5] = [0x15, 0x07, 0x0a, 0x51, 0x0b]; -/// The Subnet ID for [`RelayMappedAddr]: fd15:70a:510b:1::/64. +/// The Subnet ID for [`RelayMappedAddr`]: fd15:70a:510b:1::/64. const RELAY_MAPPED_SUBNET: [u8; 2] = [0x00, 0x01]; /// The Subnet ID for [`CustomMappedAddr`]: fd15:70a:510b:3::/64. diff --git a/krikos/src/socket/remote_map/remote_state.rs b/krikos/src/socket/remote_map/remote_state.rs index cbc74aa5c70..831924429b9 100644 --- a/krikos/src/socket/remote_map/remote_state.rs +++ b/krikos/src/socket/remote_map/remote_state.rs @@ -1374,7 +1374,7 @@ impl State { } } - /// Returns the [`transports::FourTuple] for a path. + /// Returns the [`transports::FourTuple`] for a path. fn transport_tuple_for_path(&self, path: &noq::Path) -> Option { let noq_network_path = path.network_path().ok()?; transports::FourTuple::from_noq( @@ -1826,7 +1826,7 @@ impl Future for OnClosed { } } -/// Converts an iterator of [`TransportAddr'] into an iterator of [`transports::Addr`]. +/// Converts an iterator of [`TransportAddr`] into an iterator of [`transports::Addr`]. fn to_transports_addr( endpoint_id: EndpointId, addrs: impl IntoIterator, diff --git a/krikos/src/socket/transports.rs b/krikos/src/socket/transports.rs index 26d2ad2d8ec..7f912c4dee4 100644 --- a/krikos/src/socket/transports.rs +++ b/krikos/src/socket/transports.rs @@ -1054,7 +1054,7 @@ impl FourTuple { } } - /// Returns the [`FourTuple] for a noq network path by looking up QUIC-mapped addresses. + /// Returns the [`FourTuple`] for a noq network path by looking up QUIC-mapped addresses. pub(super) fn from_noq( noq_four_tuple: noq::FourTuple, relay_mapped_addrs: &AddrMap<(RelayUrl, EndpointId), RelayMappedAddr>, diff --git a/protocols/krikos-blobs/README.md b/protocols/krikos-blobs/README.md index c1479ac840a..9e38952bc61 100644 --- a/protocols/krikos-blobs/README.md +++ b/protocols/krikos-blobs/README.md @@ -69,7 +69,8 @@ async fn main() -> anyhow::Result<()> { ## Examples -Examples that use `krikos-blobs` can be found in [this crate's `examples/` directory](examples). +Examples that use `krikos-blobs` can be found in +[this crate's `examples/` directory](https://github.com/holon-technologies/iroh/tree/main/protocols/krikos-blobs/examples). # License diff --git a/protocols/krikos-blobs/src/api.rs b/protocols/krikos-blobs/src/api.rs index 535e7cf4117..81ede763aee 100644 --- a/protocols/krikos-blobs/src/api.rs +++ b/protocols/krikos-blobs/src/api.rs @@ -5,13 +5,12 @@ //! //! The entry point for the api is the [`Store`] struct. There are several ways //! to obtain a `Store` instance: it is available via [`Deref`] -//! from the different store implementations -//! (e.g. [`MemStore`](crate::store::mem::MemStore) -//! and [`FsStore`](crate::store::fs::FsStore)) as well as on the +//! from the different store implementations (e.g. [`MemStore`](crate::store::mem::MemStore) and, +//! with the `fs-store` feature, `FsStore`) as well as on the //! [`BlobsProtocol`](crate::BlobsProtocol) krikos protocol handler. //! -//! You can also [`connect`](Store::connect) to a remote store that is listening -//! to rpc requests. +//! With the `rpc` feature, you can also use `Store::connect` to connect to a remote store that is +//! listening for RPC requests. use std::{io, ops::Deref}; use bao_tree::io::EncodeError; diff --git a/protocols/krikos-blobs/src/api/proto.rs b/protocols/krikos-blobs/src/api/proto.rs index eb7d183853d..40e65ffc06f 100644 --- a/protocols/krikos-blobs/src/api/proto.rs +++ b/protocols/krikos-blobs/src/api/proto.rs @@ -8,8 +8,8 @@ //! and responses. The enum containing the full requests is [`Command`]. These are the //! commands you will have to handle in a store actor handler. //! -//! This crate provides a file system based store implementation, [`crate::store::fs::FsStore`], -//! as well as a mutable in-memory store and an immutable in-memory store. +//! With the `fs-store` feature, this crate provides the filesystem-based `FsStore`, as well as a +//! mutable in-memory store and an immutable in-memory store. //! //! The file system store is quite complex and optimized, so to get started take a look at //! the much simpler memory store. diff --git a/protocols/krikos-blobs/src/lib.rs b/protocols/krikos-blobs/src/lib.rs index d0d0ef5e8c8..a1af7b3db04 100644 --- a/protocols/krikos-blobs/src/lib.rs +++ b/protocols/krikos-blobs/src/lib.rs @@ -7,9 +7,9 @@ //! It implements a [protocol] for streaming content-addressed data transfer using //! [BLAKE3] verified streaming. //! -//! It also provides a [store] module for storage of blobs and outboards, -//! as well as a [persistent](crate::store::fs) and a [memory](crate::store::mem) -//! store implementation. +//! It also provides a [store] module for storage of blobs and outboards, including a +//! [memory](crate::store::mem) implementation and, with the `fs-store` feature, a persistent +//! filesystem implementation. //! //! To implement a server, the [provider] module provides helpers for handling //! connections and individual requests given a store. diff --git a/protocols/krikos-blobs/src/provider.rs b/protocols/krikos-blobs/src/provider.rs index e44bc5a333a..23bbb94e53e 100644 --- a/protocols/krikos-blobs/src/provider.rs +++ b/protocols/krikos-blobs/src/provider.rs @@ -2,7 +2,7 @@ //! //! Note that while using this API directly is fine, the standard way //! to provide data is to just register a [`crate::BlobsProtocol`] protocol -//! handler with an [`krikos::Endpoint`](krikos::protocol::Router). +//! handler with a [`krikos::protocol::Router`]. use std::{fmt::Debug, future::Future, io}; use bao_tree::ChunkRanges; @@ -252,10 +252,10 @@ impl WriteProgress for WriterContext { } } -/// Wrapper for a [`noq::SendStream`] with additional per request information. +/// Wrapper for a [`SendStream`] with additional per-request information. #[derive(Debug)] pub struct ProgressWriter { - /// The noq::SendStream to write to + /// The send stream to write to. pub inner: W, pub(crate) context: WriterContext, } diff --git a/protocols/krikos-blobs/src/store/gc.rs b/protocols/krikos-blobs/src/store/gc.rs index f29afc4b25a..2c7ec4cd0b2 100644 --- a/protocols/krikos-blobs/src/store/gc.rs +++ b/protocols/krikos-blobs/src/store/gc.rs @@ -154,7 +154,7 @@ pub struct GcConfig { /// Returned from [`ProtectCb`]. /// -/// See [`GcConfig::add_protected] for details. +/// See [`GcConfig::add_protected`] for details. #[derive(Debug)] pub enum ProtectOutcome { /// Continue with the garbage collection run. @@ -165,7 +165,7 @@ pub enum ProtectOutcome { /// The type of the garbage collection callback. /// -/// See [`GcConfig::add_protected] for details. +/// See [`GcConfig::add_protected`] for details. pub type ProtectCb = Arc< dyn for<'a> Fn( &'a mut HashSet, diff --git a/protocols/krikos-blobs/src/store/mod.rs b/protocols/krikos-blobs/src/store/mod.rs index 3a060dac84a..27d75df63ed 100644 --- a/protocols/krikos-blobs/src/store/mod.rs +++ b/protocols/krikos-blobs/src/store/mod.rs @@ -1,9 +1,9 @@ //! Store implementations //! //! Use the [`mem`] store for sharing a small amount of mutable data, -//! the [`readonly_mem`] store for sharing static data, and the [`fs`] store -//! for when you want to efficiently share more than the available memory and -//! have access to a writeable filesystem. +//! the [`readonly_mem`] store for sharing static data, and, with the `fs-store` feature, the `fs` +//! store for when you want to efficiently share more than the available memory and have access to +//! a writeable filesystem. use bao_tree::BlockSize; #[cfg(feature = "fs-store")] #[cfg_attr(krikos_blobs_docsrs, doc(cfg(feature = "fs-store")))] diff --git a/protocols/krikos-docs/src/ranger.rs b/protocols/krikos-docs/src/ranger.rs index 7a6c7d3b156..e63cd24d454 100644 --- a/protocols/krikos-docs/src/ranger.rs +++ b/protocols/krikos-docs/src/ranger.rs @@ -1102,7 +1102,7 @@ mod tests { #[tokio::test] async fn test_multikey() { - /// Uses the blanket impl of [`RangeKey]` for `T: AsRef<[u8]>` in this module. + /// Uses the blanket impl of [`RangeKey`] for `T: AsRef<[u8]>` in this module. #[derive(Default, Clone, PartialEq, Eq, PartialOrd, Ord)] struct Multikey { author: [u8; 4], diff --git a/protocols/krikos-identity/Cargo.toml b/protocols/krikos-identity/Cargo.toml new file mode 100644 index 00000000000..992cc0303dd --- /dev/null +++ b/protocols/krikos-identity/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "krikos-identity" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Distributed multi-device identity and authorization for Krikos" +keywords = ["identity", "authorization", "cryptography", "peer-to-peer"] +categories = ["authentication", "cryptography", "network-programming"] +publish = false + +[lints] +workspace = true + +[dependencies] +argon2 = { version = "0.5.3", default-features = false, features = ["alloc"] } +blake3 = { version = "1.8", default-features = false } +chacha20poly1305 = { version = "0.11", default-features = false, features = ["alloc", "zeroize"] } +curve25519-dalek = { version = "5", default-features = false } +data-encoding = "2.6" +getrandom = { version = "0.4", default-features = false, optional = true } +krikos-base = { workspace = true, default-features = false, features = ["key-types"] } +krikos = { workspace = true, default-features = false, optional = true } +postcard = { version = "=1.1.3", features = ["use-std"] } +rand_core = { version = "0.10", default-features = false } +redb = { version = "4.1", optional = true } +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", default-features = false, features = ["io-util", "rt", "sync", "time"], optional = true } +tokio-util = { version = "0.7", default-features = false, features = ["rt"], optional = true } +x25519-dalek = { version = "3", default-features = false, features = ["static_secrets", "zeroize"] } +zeroize = { version = "1.9", default-features = false, features = ["alloc"] } + +[dev-dependencies] +futures-lite = "2" +hex = "0.4" +krikos = { workspace = true, default-features = false, features = ["test-utils", "tls-ring"] } +proptest = "1.11" +serde_json = "1" +tempfile = "3.20" +tokio = { version = "1", features = ["io-util", "macros", "rt", "sync", "time"] } + +[features] +default = [] +fs-store = ["dep:redb"] +net = ["dep:krikos", "dep:tokio", "dep:tokio-util"] +os-rng = ["dep:getrandom"] +provider-store = ["dep:redb"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "krikos_docsrs"] + +[[example]] +name = "generate_interop_vectors" +required-features = ["net"] diff --git a/protocols/krikos-identity/README.md b/protocols/krikos-identity/README.md new file mode 100644 index 00000000000..769585e41f7 --- /dev/null +++ b/protocols/krikos-identity/README.md @@ -0,0 +1,640 @@ +# krikos-identity + +`krikos-identity` is a distributed account-control and authorization protocol. It +keeps a stable account identity separate from every replaceable Krikos endpoint and +application device. + +The crate is under active implementation and is not yet a stable protocol release. +Its core is deterministic and effect-free; transport, persistence, transparency, +and recovery adapters are layered on that core. + +Stable publication is blocked by the separate machine-readable +[`release-gate.toml`](release-gate.toml). Its six approval criteria, evidence requirements, and +opening procedure are documented in [`docs/release-gate.md`](docs/release-gate.md). + +Provider database configuration, crash recovery, compaction, auditor usage, durable effect +reconciliation, and private-safe metrics are documented in +[`docs/provider-operations.md`](docs/provider-operations.md). The broader deployment, threat, +privacy, and recovery boundary is documented in +[`docs/security-and-deployment.md`](docs/security-and-deployment.md). The implementation and +verification index is [`docs/design-evidence.md`](docs/design-evidence.md). + +## Optional runtime integrations + +The default feature set remains runtime-, storage-, and ambient-entropy-independent. APIs that +accept a caller-owned cryptographic RNG remain in the default core. `os-rng` enables only the +fallible convenience methods that obtain fresh secrets from the operating system; it does not +change protocol behavior or wire formats. `net` enables the six bounded Krikos v1 ALPN handlers and +the completed-handshake pairing exporter adapter; `fs-store` enables redb account and durable +pairing-nonce storage; and `provider-store` enables redb-backed provider persistence. These features +are independent and none implicitly enables `os-rng`. + +The network handlers accept one length-delimited canonical request per authenticated connection, +enforce the 4 MiB frame and 16 MiB session ceilings, and route decoded requests through a +caller-owned `IdentityProtocolService`. Sync pages always come from the configured `AccountStore` +at a cursor-authenticated frozen source revision. + +The negotiated ALPN bytes and maximum canonical request payloads, excluding the four-byte transport +length prefix, are fixed as follows: + +| Protocol | Exact v1 ALPN bytes | Maximum request payload | +| --- | --- | ---: | +| Pairing | `krikos-identity/pairing/1` | 16 KiB | +| Synchronization | `krikos-identity/sync/1` | 4 MiB | +| Authorization proposal | `krikos-identity/proposal/1` | 256 KiB | +| Account checkpoint | `krikos-identity/checkpoint/1` | 1 MiB | +| Transparency gossip | `krikos-identity/transparency-gossip/1` | 1 MiB | +| Recovery | `krikos-identity/recovery/1` | 256 KiB | + +Each handler applies its request-specific payload ceiling before canonical decoding. The 4 MiB +per-frame and 16 MiB per-connection session ceilings remain independent transport limits. + +`krikos-app` exposes a separate opt-in `identity` feature and `IdentityProtocolComponent`. The +component registers all six handlers on the endpoint already created by the standard bundle and +uses a caller-supplied account store. It never reads, replaces, or writes the framework's +`IdentityStore`, which continues to own only endpoint-key persistence. A runnable default-deny +composition is available with: + +```console +cargo run -p krikos-app --features identity --example identity +``` + +## Canonical wire profile v1 + +Signed v1 structures documented in this profile use exactly Postcard 1.1.3. The dependency is +pinned, and the rules below—not future Serde or Postcard behavior—are normative for the structures +explicitly listed here. Checked-in vectors provide byte-level release evidence only when their +repository gate is green. The complete provider-portability manifest/chunk schemas, provider-only +registries, commitment preimages, resource bounds, and persistent-store compatibility rules are +normative in [`docs/provider-operations.md`](docs/provider-operations.md). + +### Language-independent interoperability catalog + +[`tests/vectors/manifest.json`](tests/vectors/manifest.json) is descriptive JSON metadata for the +canonical binary files beside it; JSON is never hashed, signed, MACed, or decoded as protocol +wire. Manifest format v2 uses binding-schema v1 and derivation-schema v1. Each vector records its +exact canonical file, hex bytes, BLAKE3 file digest, byte length, wire type, version scope, +algorithm codes, expected identifiers, repeatable signature/MAC bindings, recursive derivations, +exact object dependencies, and a bounded tamper expectation. + +The non-generating validator in +[`tests/interop_vectors.rs`](tests/interop_vectors.rs) owns a closed typed inventory independently +of the generator. It binds every name to its exact wire type and, where one wire type has several +semantic roles, to the operation, response, or migration/checkpoint phase. It recursively derives +authenticated subobjects from decoded binary bytes, recomputes identifiers and Merkle +relationships, verifies every signature and MAC, compares exact dependencies, and rejects +omission, substitution, coordinated replacement, extra-file, or missing-file mutations. The +current v2 catalog contains 141 required vectors, including all 22 account operations, the complete +account/recovery/migration/pairing ceremonies, network envelopes, Merkle structures, and provider +export/recovery/compaction/anchor boundaries. + +`GuardianGrant` and `GuardianGrantOpening` are deliberately private nested witnesses and have no +standalone public wire form; both are covered inside `SignedGuardianApproval`. The public +`PairingConfirmation` is a transient state-machine input consumed before retained proposal +construction; its manifest disposition points to `PairingConfirmationContext` and +`DeviceAuthorizationProposal` vectors. These are the only declared catalog dispositions. + +[`../../scripts/check-identity-interop-vectors.sh`](../../scripts/check-identity-interop-vectors.sh) +regenerates the catalog in a temporary directory, requires byte-for-byte equality, reproduces the +provider and sync fuzz corpora, and runs the read-only validator. An independently maintained +implementation consuming these assets remains an external stable-release gate, not repository +self-certification. + +Postcard encodes unsigned integers as minimal unsigned LEB128 varints, `false` and +`true` as `0x00` and `0x01`, fixed arrays/tuples as their concatenated elements with +no length, and sequences/byte strings as a minimal-varint item count followed by +their elements. Struct fields appear in the documented declaration order with no +field tags or enclosing length. Closed protocol enums are explicit unsigned `u16` +codepoints; Serde variant ordinals are not used. + +The foundational schemas, in field order, are: + +```text +Digest = (hash_algorithm: u16, bytes: [u8; 32]) +SigningPublicKey = (signature_algorithm: u16, bytes: [u8; 32]) +AgreementPublicKey = (agreement_algorithm: u16, bytes: [u8; 32]) +ProtocolSignature = (signature_algorithm: u16, bytes: [u8; 64]) +Extension = (code: u32, critical: bool, value: bytes) +Extensions = sequence +``` + +An extension code must be nonzero. Extension sequences are strictly increasing by +code, contain at most 32 fields, contain at most 16 KiB per value and 64 KiB across +all values, and preserve unknown non-critical values byte-for-byte. An unknown +critical code fails closed. + +Additional canonical requirements are: + +- every independently signed or authoritative top-level structure carries an + explicit protocol version; nested primitives do not; +- algorithm registries use unsigned 16-bit codepoints (`1` is the initial suite); +- schemas contain no maps, floats, `usize`, or unordered collections; +- set-like vectors are sorted and duplicate-free before encoding; +- integers use Postcard's minimal varint representation; +- decoders reject trailing bytes, non-minimal encodings, unsupported codepoints, + and inputs larger than the type's named bound; +- every protocol-owned hash input is + `ASCII("KRIKOS-ID//v1") || 0x00 || canonical_object_bytes`; +- JSON and other human-readable formats are never signing formats. + +Initial codepoints are BLAKE3-256 (`hash = 1`), Ed25519 (`signature = 1`), +X25519 (`agreement = 1`), BLAKE3 derive-key (`KDF = 1`), and +XChaCha20-Poly1305 (`AEAD = 1`). Golden bytes in `tests/vectors.rs` freeze the +foundational profile. + +### Synchronization and network-envelope schema + +The four-byte big-endian stream length is transport framing and is not part of the canonical +payload. The canonical synchronization and optional `net` feature structures use these exact field +orders: + +| Structure | Exact v1 fields | +|---|---| +| `SyncCursor` | `(protocol_version, account_id, source_heads: sequence, next_item: u64, delivered_bytes: u64, authenticator: [u8; 32])` | +| `SyncRequest` | `(protocol_version, account_id, known_heads: sequence, continuation: option, max_events: u16, max_frame_bytes: u32)` | +| `SyncFrame` | `(protocol_version, account_id, source_heads: sequence, events: sequence, continuation: option)` | +| `SyncResponse` | `(protocol_version, response_code: u16, frame: option, complete_account_id: option, complete_heads: option>)` | +| `EndpointAuthorizationRequest` | `(protocol_version, account_id, checkpoint_id, device_id)` | +| `AuthorizedSyncRequest` | `(authorization: EndpointAuthorizationRequest, request: SyncRequest)` | +| `AuthorizedProposalRequest` | `(authorization: EndpointAuthorizationRequest, proposal: DeviceAuthorizationProposal)` | +| `AuthorizedCheckpointRequest` | `(authorization: EndpointAuthorizationRequest, checkpoint: SignedCheckpoint)` | +| `IdentityProtocolAck` | `(protocol_version, protocol_code: u16, request_commitment, decision_code: u16)` | +| `IdentityProtocolReply` | `(protocol_version, reply_code: u16, ack: option, sync: option)` | + +`SyncResponse` uses closed response code `1` for exactly one `frame` and code `2` for exactly one +`(complete_account_id, complete_heads)` pair. `IdentityProtocolReply` uses code `1` for exactly one +acknowledgement and code `2` for exactly one sync response. The acknowledgement protocol registry is +pairing `1`, sync `2`, proposal `3`, checkpoint `4`, transparency gossip `5`, and recovery `6`; +decision code `0` means accepted and nonzero values are caller-owned rejection codes. Old Serde enum +ordinals and every unknown response, reply, or protocol codepoint fail closed. + +Every authorized envelope requires its outer authorization account to equal the nested request, +proposal, or checkpoint account during construction and canonical decoding. Endpoint authorization +is an authoritative top-level coordinate and therefore carries and validates `protocol_version = 1`. + +Ed25519 public keys must decompress under ed25519-dalek's RFC 8032 encoding rules +and must not be a weak/small-order point. X25519 public keys are canonical +little-endian field elements strictly below `2^255 - 19`; inputs that produce an +all-zero result under a clamped contributory probe are rejected. Actual key agreement +also rejects an all-zero shared secret. + +## Authoritative account operation registry + +| Code | Operation | +|---:|---| +| 1 | `AuthorizeDevice` | +| 2 | `UpdateDeviceAuthorization` | +| 3 | `UpdateDeviceMetadata` | +| 4 | `SuspendDevice` | +| 5 | `ReinstateDevice` | +| 6 | `RevokeDevice` | +| 7 | `RotateDeviceKeys` | +| 8 | `AddController` | +| 9 | `RemoveController` | +| 10 | `ChangeControlPolicy` | +| 11 | `ChangeRecoveryPolicy` | +| 12 | `ChangeProviderPolicy` | +| 13 | `BeginRecovery` | +| 14 | `VetoRecovery` | +| 15 | `CancelRecovery` | +| 16 | `FinalizeRecovery` | +| 17 | `ResolveFork` | +| 18 | `BeginCryptoMigration` | +| 19 | `ActivateCryptoMigration` | +| 20 | `RetireCryptoSuite` | +| 21 | `UpgradeProtocol` | +| 22 | `RetireAccount` | +| 23 | reserved; checkpoint publication is non-authoritative | + +All other v1 operation codes are rejected. Account-level field schemas and their +golden vectors are frozen with the complete typed account schema below. + +## Canonical account schema reference + +The notation below is wire notation, not Rust layout. `sequence` is a +Postcard sequence whose decoded item count must not exceed `N`; `option` is +Postcard's closed option encoding. Every field list is in exact canonical order. +All `Extensions` fields are final so an extension cannot change the meaning of a +preceding field. The decoder reconstructs validated domain types and rejects a +wire value whose order, uniqueness, relationship, version, or closed codepoint is +invalid. + +### Genesis, descriptors, and policies + +| Structure | Exact v1 fields | +|---|---| +| `AccountGenesis` | `(protocol_version, account_nonce: [u8; 32], created_at, hash_algorithm, initial_policy, initial_controllers: sequence, initial_recovery_policy, initial_provider_policy, extensions)` | +| `ControllerDescriptor` | `(protocol_version, signing_key, class, weight, scope, extensions)` | +| `ProviderDescriptor` | `(protocol_version, signing_key, extensions)` | +| `DeviceDescriptor` | `(protocol_version, application_signing_key, agreement_key, endpoint_key, extensions)` | +| `PolicyRule` | `(operation, required_weight, eligible_controllers, freshness, delay: option, extensions)` | +| `ControlPolicy` | `(protocol_version, rules: sequence, default_deny: bool, extensions)` | +| `ProviderFreshness` | `(required: ProviderQuorum, maximum_age: DurationMillis)` | +| `ReplicatedProviderPolicy` | `(providers: sequence, sufficient_threshold, preferred_replication, maximum_evidence_age, rotation_rule)` | +| `ProviderPolicy` | `(protocol_version, policy_version, mode, extensions)` | +| `ControllerThreshold` | `(selector, required_weight)` | +| `GuardianThreshold` | `(guardian_set_root, guardian_count: u16, total_weight: u64, required_weight)` | +| `RecoveryPolicy` | `(protocol_version, policy_version, authority, delay, lifetime, extensions)` | + +`account_nonce` is nonzero. Genesis contains one to 64 controllers, sorted by +`ControllerId`, with no repeated identifier or signing key; both policy revisions +must be their genesis revision. The control and controller-recovery thresholds +must be satisfiable by the initial controller set. `DeviceDescriptor` uses three +independent public-key roles: application Ed25519 signing, X25519 agreement, and +Krikos endpoint Ed25519 signing; the three byte strings must be pairwise different. + +The closed policy encodings are: + +| Registry | Code | Payload | +|---|---:|---| +| `ControllerClass` | 1 | `PersonalDevice` | +| | 2 | `HardwareSecurityKey` | +| | 3 | `OfflineRecovery` | +| | 4 | `GuardianAccount` | +| | 5 | `Institutional` | +| `ControllerScope` | 1 | empty operation sequence (`AllV1Operations`) | +| | 2 | nonempty sorted unique operation sequence | +| `ControllerSelector` | 1 | `(none, none)` (`AnyActive`) | +| | 2 | `(some ControllerIdSet, none)` | +| | 3 | `(none, some ControllerClassSet)` | +| `FreshnessRequirement` | 1 | `none` (`LatestKnown`) | +| | 2 | `some ProviderFreshness` | +| `ProviderRotationRule` | 1 | `AccountEventOnly` | +| `ProviderMode` | 1 | `none` (`LocalOnly`) | +| | 2 | `some ReplicatedProviderPolicy` | +| `RecoveryAuthority` | 1 | `(some ControllerThreshold, none)` | +| | 2 | `(none, some GuardianThreshold)` | + +Control-policy rules are nonempty, sorted uniquely by operation code, and always +default-deny. Explicit controller sets and class sets are nonempty, bounded to 64, +sorted, and duplicate-free. A replicated provider policy has one to 16 provider +descriptors sorted uniquely by self-certifying `ProviderId`; its nonzero quorum +satisfies `sufficient_threshold <= preferred_replication <= provider_count`. +Recovery exposes either a controller threshold or only the blinded guardian-set +root and aggregate count/weights. Guardian identities and individual weights are +not public policy fields. Recovery delay and lifetime are nonzero and +`lifetime > delay`. + +### Capabilities and delegation + +| Structure | Exact v1 fields | +|---|---| +| `CapabilityNamespace` / `CapabilityAction` | nonempty UTF-8 bytes | +| `ResourceSegment` | nonempty opaque bytes | +| `ResourcePath` | nonempty semantic-order sequence of `ResourceSegment` | +| `ResourceSelector` | `(code: u16, path: ResourcePath)` | +| `CapabilityConstraint` | `(code: u16, value: u64)` | +| `DelegationPermission` | `(code: u16, remaining_depth: u8)` | +| `CapabilityGrant` | `(protocol_version, namespace, action, resource, constraints, delegation, expires_at, extensions)` | +| `AuthorizationContext` | `(account_id, epoch, checkpoint_id)` | +| `CapabilityRoot` | `(authorization_context, holder, grant, extensions)` | +| `DelegationBody` | `(protocol_version, parent_grant_id, child_grant, issuer, subject, authorization_context, issued_at, nonce: [u8; 16], extensions)` | +| `SignedDelegation` | `(body, signature)` | +| `DelegationChain` | `(root, links)` | + +| Registry | Code | Meaning | +|---|---:|---| +| `ResourceSelector` | 1 | exact complete path | +| | 2 | complete-segment prefix | +| `CapabilityConstraint` | 1 | account epoch at least `value` | +| | 2 | account epoch at most `value` | +| | 3 | valid from Unix millisecond `value` | +| `DelegationPermission` | 1 | not delegable; depth must be zero | +| | 2 | delegable; depth is 1 through 8 | + +Namespace and action are each at most 128 UTF-8 bytes. A resource selector is at +most 1,024 canonical bytes and contains at most 64 nonempty segments. Constraints +are sorted uniquely by code, conjunctive, and limited to 32. A delegation chain +contains one to eight links in parent-to-child semantic order, stays within one +account context, contains no device/grant/delegation cycle, and every child must +strictly narrow its parent in resource, constraints, expiration, or remaining +delegation depth without changing namespace or action. + +### Devices, application events, and group-key wraps + +| Structure | Exact v1 fields | +|---|---| +| `BlindedMetadataCommitment` | `[u8; 32]` | +| `DeviceAuthorization` | `(protocol_version, device_id, descriptor, device_class, metadata_commitment, capabilities, authorization_epoch, extensions)` | +| `DeviceAuthorizationUpdate` | `(protocol_version, device_id, device_class, capabilities, authorization_epoch, extensions)` | +| `DeviceMetadataUpdate` | `(protocol_version, device_id, metadata_commitment, extensions)` | +| `DeviceUpdate` | `(code: u16, authorization-or-metadata payload)` | +| `SuspendDevice` / `ReinstateDevice` | `(protocol_version, device_id, extensions)` | +| `RevokeDevice` | `(protocol_version, device_id, reason_code, extensions)` | +| `RotateDeviceKeys` | `(protocol_version, old_device_id, new_authorization, extensions)` | +| `ApplicationEventCounter` | `u64` | +| `ApplicationEventBody` | `(protocol_version, account_id, application_id, device_id, account_epoch, checkpoint_id, local_counter, payload, extensions)` | +| `SignedApplicationEvent` | `(body, signature)` | +| `AgreementKeyId` | `Digest` of `(recipient_device_id, recipient_agreement_key)` under `KRIKOS-ID/agreement-key/v1` | +| `KeyWrapNonce` | `[u8; 24]` | +| `GroupKeyWrapHeader` | `(protocol_version, crypto_suite_id, account_id, application_id, group_id, authorizing_account_epoch, group_key_epoch, recipient_device_id, recipient_agreement_key_id, ephemeral_public_key, nonce, extensions)` | +| `WrappedGroupKey` | `(header, ciphertext, extensions)` | +| `RecipientKeyWraps` | nonempty recipient-ordered sequence of `WrappedGroupKey` | + +| Registry | Code | Meaning | +|---|---:|---| +| `DeviceClass` | 1 | `GeneralPurpose` | +| | 2 | `HardwareBacked` | +| | 3 | `ApplicationOnly` | +| | 4 | `Service` | +| `DeviceUpdate` | 1 | authorization-changing replacement | +| | 2 | metadata-commitment-only replacement | + +A device authorization binds `device_id` to the exact `DeviceDescriptor` and +contains at most 128 capability grants sorted uniquely by `CapabilityGrantId`. +An authorization update replaces the complete class/capability set and advances +security authority; a metadata update only changes or clears the blinded private +metadata commitment. The public 32-byte commitment must contain at least eight +distinct byte values, but that structural check is not proof of entropy: producers +must blind private metadata with fresh randomness. Rotation atomically revokes an +old `DeviceId` and installs a different complete authorization. + +`ApplicationEventCounter` orders one device's events for one application only; it +does not claim a cross-device total order. An application event signs the exact +account epoch and checkpoint used for authorization. Its opaque payload is at most +1 MiB minus 4 KiB, while the complete signed envelope is at most 1 MiB. +`ApplicationEventId` derives from the complete signed envelope, including the +device signature. The application signature message is exactly +`b"KRIKOS-ID/application-event-signature/v1\0" || canonical(ApplicationEventBody)`. + +The fixed v1 key-wrap suite is X25519, BLAKE3 derive-key, and +XChaCha20-Poly1305. Its 32-byte AEAD key is exactly +`BLAKE3 derive_key("KRIKOS-ID/group-key-wrap-key/v1", shared_secret || +ephemeral_public_key || recipient_public_key)`, where all three inputs are their +raw 32-byte X25519 values in that order. AEAD associated data is the canonical +encoding of `(GroupKeyWrapHeader, WrappedGroupKey.extensions)`, binding both the +header and preserved noncritical outer extensions. The nonce is exactly 24 bytes, +the plaintext group key is exactly 32 bytes, and the ciphertext plus tag is +therefore exactly 48 bytes. Both the ephemeral X25519 secret and nonce must be +generated freshly and independently for every recipient; all-zero/non-contributory +DH output is rejected. A recipient set contains at most 1,024 wraps and at most +1 MiB total, is sorted uniquely by `DeviceId`, shares one distribution context, +and does not reuse an ephemeral public key or nonce. Rotation starts from a +validated post-state snapshot binding the exact account revision and complete +application-group membership; output recipients must match that snapshot exactly. +Snapshot construction accepts `Active`, `MigrationPending`, and `MigrationDual` +account projections. The two migration phases are intentionally eligible because +v1 controller-signature migration does not change the fixed X25519 key-wrap suite. +Recovery-pending, forked, upgraded/read-only, and retired projections are rejected +with their typed lifecycle errors before recipient processing. + +`rotate_group_key_with_rng` in the default core, and the `os-rng` convenience function +`rotate_group_key`, return a local, non-wire `GroupKeyRotation` artifact containing the fixed +suite, exact `AccountRevision` (including the complete sorted head set), +account/application/group identifiers, account and group-key epochs, exact expected recipient IDs, +and `RecipientKeyWraps`. Persistence must accept this complete artifact, call +`validate_current_revision` immediately before writing, and atomically compare-and-swap against +the artifact revision. Persisting bare recipient wraps is outside the safety boundary: a stale or +forked rotation must never be committed. + +### Provider evidence, admission, and checkpoints + +| Structure | Exact v1 fields | +|---|---| +| `EventPredecessors` | `(code: u16, genesis_anchor-or-event-heads)` | +| `AccountOperation` | `(operation_code: u16, typed operation payload)` | +| `EventBody` | `(protocol_version, account_id, sequence, resulting_epoch, predecessors, operation, created_at, nonce: [u8; 16], extensions)` | +| `AuthorizedEvent` | `(body, admission_evidence, approvals)` | +| `KeyedSignature` | `(crypto_suite_id, controller_key_id, signature)` | +| `EventIntentApprovalBody` | `(protocol_version, controller_id, proposal_id, extensions)` | +| `SignedEventIntentApproval` | `(body, signatures: sequence)` | +| `EventIntentApprovals` | sorted nonempty sequence of at most 64 signed intent approvals | +| `ProviderLogEntryBody` | `(protocol_version, provider_id, log_id, account_id, subject, observed_at, extensions)` | +| `ProviderHeadBody` | `(protocol_version, provider_id, log_id, key_version, tree_size, tree_root, observed_at, extensions)` | +| `SignedProviderHead` | `(body, signature)` | +| `InclusionReceipt` | `(entry, leaf_index, audit_path, signed_head)` | +| `ProviderReceipts` | sequence of at most 16 inclusion receipts | +| `FreshnessEvidence` | `(code: u16, local-or-provider payload)` | +| `DelayEvidence` | `(code: u16, none-or-provider payload)` | +| `AdmissionEvidence` | `(protocol_version, proposal_id, preceding_checkpoint, provider_policy_id, freshness, delay, extensions)` | +| `ControllerApprovalBody` | `(protocol_version, controller_id, subject, extensions)` | +| `SignedControllerApproval` | `(body, signatures: sequence)` | +| `ControllerApprovals` | sorted nonempty sequence of at most 64 signed controller approvals | +| `CheckpointBody` | `(protocol_version, account_id, account_epoch, sequence, event_head, state_root, authorized_set_root, revoked_set_root, control_policy_id, recovery_policy_id, provider_policy_id, crypto_state_id, lifecycle, issued_at, extensions)` | +| `CheckpointAuthorization` | `(code: u16, controller-approvals-or-transition payload)` | +| `TransitionCheckpointWitness` | `(protocol_version, transition_kind, event_id, event_authorization_id)` | +| `SignedCheckpoint` | `(body, authorization)` | + +| Registry | Code | Payload | +|---|---:|---| +| `EventPredecessors` | 1 | `GenesisAnchor` | +| | 2 | nonempty sorted unique `EventId` sequence (at most 16) | +| `ProviderLogSubject` | 1 | `CheckpointId` | +| | 2 | `ProposalId` event intent | +| `FreshnessEvidence` | 1 | `CheckpointId` known locally | +| | 2 | `(checkpoint_id, provider_policy_id, receipts)` | +| `DelayEvidence` | 0 | unit / no-delay policy | +| | 1 | `(provider_policy_id, required_quorum, observed_at, intent_approvals, receipts)` | +| controller approval subject | 1 | `(event_id, admission_evidence_id)` | +| | 2 | `CheckpointId` | +| `CheckpointAuthorization` | 1 | `ControllerApprovals` over the exact `CheckpointId` | +| | 2 | typed `TransitionCheckpointWitness` derived from `FinalizeRecovery` or `RetireAccount` | +| checkpoint transition kind | 1 | `FinalizeRecovery` | +| | 2 | `RetireAccount` | +| `AccountLifecycle` | 1 | `Active` | +| | 2 | `RecoveryPending` | +| | 3 | `MigrationPending` | +| | 4 | `MigrationDual` | +| | 5 | `UpgradePending` | +| | 6 | `Retired` | + +`AccountOperation` uses the authoritative codes 1 through 22 in the table above +and decodes each code directly into its named typed payload; it never embeds an +opaque payload byte string. Code 23 and every unknown code fail closed. `EventBody` +is at most 256 KiB, has a nonzero nonce and a sequence greater than zero, and uses +the genesis anchor only at sequence 1. Ordinary later events name exactly one +existing event head. `ResolveFork` is the only v1 operation that names multiple +predecessor heads, and that set must exactly match its complete fork descriptor. +An `AuthorizedEvent` requires its admission evidence to name the body's exact +`ProposalId`. Its final `EventId` commits both that body and the exact +`AdmissionEvidenceId`, and every final approval names the pair +`(EventId, AdmissionEvidenceId)`. The complete authorized envelope is at most 256 KiB. + +A provider receipt's entry and signed head must name the same provider and log, +and `leaf_index < tree_size`; its bottom-up Merkle audit path has at most 64 +hashes. A receipt set is sorted uniquely by provider and every receipt names the +same account and subject. Freshness receipts log the exact preceding +`CheckpointId`; delay receipts log the exact `ProposalId` whose threshold intent +approvals they accompany. The delay anchor is the q-th earliest signed +`observed_at` among the required distinct configured providers, never a caller's +local clock or the latest/most favorable provider. + +### Intent and admission-bound identifiers + +All protocol-derived identifiers use +`BLAKE3-256(domain_ascii || 0x00 || canonical_body_bytes)`. In particular: + +```text +ProposalId = H("KRIKOS-ID/account-proposal/v1", canonical(EventBody)) +AdmissionEvidenceId = H("KRIKOS-ID/admission-evidence/v1", canonical(AdmissionEvidence)) +EventId = H("KRIKOS-ID/account-event/v1", canonical((EventBody, AdmissionEvidenceId))) +EventAuthorizationId = H("KRIKOS-ID/event-authorization/v1", canonical(AuthorizedEvent)) +CheckpointId = H("KRIKOS-ID/account-checkpoint/v1", canonical(CheckpointBody)) +``` + +`ProposalId` is the circularity-free body intent named by proposal approvals and +provider delay receipts. Admission evidence therefore names `ProposalId` but does not +contain the final `EventId`. Once that evidence is fixed, its identifier and the body +derive `EventId`, and final controller approvals bind both `EventId` and +`AdmissionEvidenceId`. Different valid admissions for one body are thus detectable +same-predecessor histories, while additional approvals for the same admission merge +without changing `EventId`. `CheckpointId` excludes +`CheckpointAuthorization`, so direct controller approvals can merge and a +transition-derived witness can be attached without changing the checkpoint's +identity. A transition witness names the exact retained authorized-event envelope, +must reference the checkpoint event head, and is limited to recovery finalization or +terminal account retirement. This dependency order is acyclic: +`EventBody -> ProposalId -> AdmissionEvidence -> AdmissionEvidenceId -> EventId -> final approvals`. + +Checkpoint publication is an availability-plane action, so v1 does not accept reserved account +operation code 23. Direct checkpoint authorization instead reuses the current +`ChangeProviderPolicy` rule's selector, controller scopes, and weighted threshold. This binds +publication authority to the policy that selects the transparency providers and prevents a +multi-controller account from silently becoming a one-signer checkpoint policy. A default-deny +account that omits that rule cannot create a directly authorized checkpoint; destructive recovery +and retirement checkpoints use their retained transition witness instead. + +### Recovery and fork resolution + +| Structure | Exact v1 fields | +|---|---| +| `RecoveryAuthorityPlan` | `(protocol_version, account_id, prior_checkpoint_id, prior_event_head, recovery_policy_id, recovery_policy_version, nonce: [u8; 32], replacement_controllers, replacement_control_policy, replacement_recovery_policy, retained_devices, expires_at, extensions)` | +| `RecoveryProposal` | `(protocol_version, plan, extensions)` | +| `GuardianGrant` | `(protocol_version, protected_account_id, recovery_policy_id, guardian_account_id, guardian_signing_key, weight, valid_from_epoch, expires_at, extensions)` | +| `GuardianGrantOpening` | `(protocol_version, guardian_grant_id, grant, blinding: [u8; 32], guardian_set_root, leaf_index, audit_path, extensions)` | +| `GuardianApprovalBody` | `(protocol_version, protected_account_id, recovery_id, decision, guardian_grant_id, account_epoch, approved_at, extensions)` | +| `SignedGuardianApproval` | `(body, opening, signature)` | +| `GuardianApprovalSet` | sorted nonempty sequence of at most 16 signed guardian approvals | +| `RecoveryThresholdEvidence` | `(code: u16, recovery-policy payload)` | +| code 13 `BeginRecovery` | `(protocol_version, expected_pending_recovery, recovery_id, proposal, threshold_evidence, extensions)` | +| code 14 `VetoRecovery` | `(protocol_version, expected_pending_recovery, pre_recovery_control_policy_id, freshness, extensions)` | +| code 15 `CancelRecovery` | `(protocol_version, expected_pending_recovery, threshold_evidence, freshness, extensions)` | +| `RecoveryDelayAnchor` | `(protocol_version, account_id, recovery_id, begin_proposal_id, provider_policy_id, required_quorum, observed_at, receipts, extensions)` | +| code 16 `FinalizeRecovery` | `(protocol_version, expected_pending_recovery, delay_anchor, finalized_at, extensions)` | +| `ForkDescriptor` | `(protocol_version, account_id, common_ancestor: ForkCommonAncestor, heads, extensions)` | +| code 17 `ResolveFork` | `(protocol_version, fork_id, fork, selected_head, revoked_controllers, revoked_devices, extensions)` | + +The `GuardianGrant` and `GuardianGrantOpening` rows describe their private nested witness +encoding inside `SignedGuardianApproval`. The raw grant and opening types deliberately do not +implement `CanonicalWire` or `Clone` and cannot be exported as standalone public wire objects. + +| Registry | Code | Payload or meaning | +|---|---:|---| +| `GuardianApprovalDecision` | 1 | begin the exact recovery proposal | +| | 2 | cancel the exact pending recovery | +| `RecoveryThresholdEvidence` | 1 | `(recovery_policy_id, recovery_policy_version)`; the containing event's controller approvals complete the threshold evidence | +| | 2 | `(recovery_policy_id, recovery_policy_version, guardian_approvals)` | +| `ForkCommonAncestor` | 1 | genesis anchor for a fork between first events | +| | 2 | ordinary event ID shared by all branches | + +```text +RecoveryId = H("KRIKOS-ID/recovery/v1", canonical(RecoveryProposal)) +GuardianGrantId = H("KRIKOS-ID/guardian-grant/v1", canonical((protocol_version, GuardianGrant, blinding))) +ForkId = H("KRIKOS-ID/fork/v1", canonical((common_ancestor, sorted_heads))) +``` + +`RecoveryId` is body-only: later guardian signatures, threshold evidence, delay +receipts, and finalization do not change it. A guardian grant stays private until +its approval carries an opening. The public recovery policy commits only to the +aggregate guardian-set root, count, and threshold. An opening has a nonzero +32-byte blinding value, a leaf index below 16, and a Merkle path of at most 64 +hashes; the projection layer verifies membership cryptographically. Guardian +approval sets contain 1 through 16 entries sorted uniquely by `GuardianGrantId`. +All entries bind the same account, recovery, decision, guardian-set root, and +policy, and must use distinct guardian accounts, signing keys, and leaf indexes. +Their checked aggregate weight must satisfy the committed policy threshold. + +There is exactly one durable pending-recovery slot. `BeginRecovery` encodes an +explicit `expected_pending_recovery` option that must be `None`, so concurrent +begins fail unless the authoritative slot is vacant. The operational limit of +eight recovery attempts applies only to local, pre-admission work. A begin names +the exact body-derived recovery and the pre-recovery policy version. A veto is +authorized under the pre-recovery control policy; cancellation must meet the same +recovery-policy threshold as begin, with guardian decisions changed to `Cancel`. + +Finalization uses provider receipts for the exact begin `ProposalId`. Its +`observed_at` is deterministically the q-th earliest signed observation from the +required distinct providers. The projection layer enforces the committed delay, +lifetime, and plan expiry before atomically installing the replacement +controllers and policies. The plan explicitly retains at most 1,024 sorted +devices; every other active device is revoked. Replacement controllers are a +nonempty, sorted set of at most 64 with unique identifiers and signing keys, and +the replacement recovery-policy version cannot roll back. + +A fork descriptor contains the complete sorted set of 2 through 16 known heads, +and its common ancestor cannot also be a head. V1 resolution selects one existing +declared branch and adds only sorted, unique controller and device revocations; +it cannot synthesize new authority. The embedded `ForkId` is recomputed from the +common ancestor and complete head set. Every recovery and fork object in this +section is bounded to 256 KiB. + +### Cryptographic migration, upgrade, and retirement + +| Structure | Exact v1 fields | +|---|---| +| `CryptoSuiteDescriptor` | `(version, suite_code, hash_algorithm_code, signature_algorithm_code, agreement_algorithm_code, kdf_algorithm_code, aead_algorithm_code, extensions)` | +| `ControllerKeyBinding` | `(controller_id, old_key_id, new_signing_key, extensions)` | +| `CryptoMigrationBody` | `(version, account_id, from_suite_id, to_suite, bindings, successor_account_id, nonce: [u8; 32], extensions)` | +| `ControllerKeyBindingProof` | `(migration_id, controller_id, old_key_signature, new_key_signature)` | +| `ControllerKeyBindingProofSet` | sorted nonempty sequence of at most 64 proofs | +| code 18 `BeginCryptoMigration` | `(version, migration, proofs, extensions)` | +| code 19 `ActivateCryptoMigration` | `(version, migration_id, begin_event_id, extensions)` | +| code 20 `RetireCryptoSuite` | `(version, migration_id, mode, phase_event_id, successor_account_id, extensions)` | +| code 21 `ProtocolUpgrade` | `(version, from_major, to_major, specification_digest, compatibility, successor_account_id, extensions)` | +| code 22 `RetireAccount` | `(version, successor_account_id, reason_code, extensions)` | + +| Registry | Code | Meaning | +|---|---:|---| +| `RetireCryptoSuiteMode` | 1 | abort an unactivated candidate; successor must be absent | +| | 2 | retire the previous suite after dual activation | +| `UpgradeCompatibility` | 1 | clients that cannot validate the new major are read-only | + +At most two controller-signature suites are active during migration. Begin carries +a complete, sorted old/new key binding and cross-signature proof for every +controller; Activate enters the dual-signature phase. Code 20 is recoverable in +both directions: mode 1 aborts a failed candidate, while mode 2 retires the +previous suite after successful dual operation. A v1 in-place migration may +change only the controller signature suite; it retains BLAKE3-256, X25519, +BLAKE3 derive-key, and XChaCha20-Poly1305. A digest-breaking suite requires a +distinct successor `AccountId`. Protocol upgrade requires `to_major > from_major`; +account retirement is terminal. + +## Protocol resource bounds + +| Resource | v1 maximum | +|---|---:| +| Canonical protocol object | 1 MiB | +| Account-control event or migration payload | 256 KiB | +| Controllers / policy rules / authorization signatures | 64 each | +| Simultaneously accepted controller suites | 2 | +| Future-algorithm public key / signature | 4 KiB / 8 KiB | +| Devices retained including tombstones | 1,024 | +| Capabilities per device / constraints per capability | 128 / 32 | +| Delegation depth | 8 | +| Transparency providers / private recovery guardians | 16 / 16 | +| Merkle proof path / extension fields | 64 hashes / 32 fields | +| One extension value / aggregate extension values | 16 KiB / 64 KiB | +| Fork heads / encoded fork evidence | 16 / 4 MiB | +| Capability name / resource selector | 128 bytes / 1,024 bytes | +| Private metadata envelope | 256 KiB | +| Complete application event / application payload | 1 MiB / 1 MiB minus 4 KiB | +| Wrapped group key | 4 KiB | +| Pending proposals / live pairing tickets / recovery attempts | 128 / 64 / 8 | +| Sync frame / session | 4 MiB / 16 MiB | + +`identity_schema` fuzzes the exported sealed composite and closed-enum account-schema +decoders listed in this reference. The first byte selects a schema, the remaining +input is rejected above 1 MiB, and every accepted value must re-encode +byte-for-byte identically. CI runs this target under the same explicit time, +memory, input, and artifact limits as the other reviewed fuzz targets. + +`identity_capability` drives the pure capability evaluator with bounded direct and +one-to-eight-link delegated proofs. Its 64-byte control input varies lifecycle, +historical grant possession, authenticated context lineage and timestamps, +constraints, revocations, signatures, request scope, and stale authorization +contexts without constructing an unbounded protocol collection. + +`identity_pairing` fuzzes the bounded canonical pairing ticket, complete transcript, +four-role possession proof, consumed authorization proposal, presence challenge, and +presence proof decoders. Its dispatch byte is followed by at most 256 KiB, and every +accepted value must reproduce the input byte-for-byte. diff --git a/protocols/krikos-identity/docs/design-evidence.md b/protocols/krikos-identity/docs/design-evidence.md new file mode 100644 index 00000000000..996d78ef0a3 --- /dev/null +++ b/protocols/krikos-identity/docs/design-evidence.md @@ -0,0 +1,212 @@ +# Design-to-evidence map + +This document maps the identity architecture requirements to the repository artifacts that +implement or verify them. It is an audit index, not a normative wire specification. The foundational, +account-control, synchronization, and network-envelope profile currently documented in +[`../README.md`](../README.md) is normative for that scope; the provider-portability appendix and +operational procedures are normative in [`provider-operations.md`](provider-operations.md), and +deployment rules remain in +[`security-and-deployment.md`](security-and-deployment.md). + +Evidence labels have precise meanings: + +- **Implemented** means the named repository source and focused tests exist. +- **Repository gate** means acceptance depends on the named command remaining green. +- **External gate** means the release requires evidence that this repository cannot honestly + manufacture. It remains a release prerequisite. +- **Non-goal** means the design intentionally excludes the behavior. + +No entry here turns availability evidence into authority, a unit test into a security audit, or a +single Rust implementation into independent interoperability evidence. + +## Architecture requirement map + +| Requirement area | Authoritative implementation and verification evidence | +| --- | --- | +| Executive Summary | Crate boundary and authority model in [`../src/lib.rs`](../src/lib.rs); deterministic projection in [`../src/state.rs`](../src/state.rs); deployment summary in [`security-and-deployment.md`](security-and-deployment.md). | +| Problem Statement | Stable account/device/controller separation in [`../src/genesis.rs`](../src/genesis.rs), [`../src/keys.rs`](../src/keys.rs), and [`../src/device.rs`](../src/device.rs); rotation/revocation histories in `tests/state_machine.rs`. | +| Goals and non-goals | Scope, goals, and explicit exclusions in [`security-and-deployment.md`](security-and-deployment.md); crate-level release gates in [`../src/lib.rs`](../src/lib.rs). Mandatory chains/tokens, civil identity, universal reputation, global application ordering, retroactive plaintext erasure, and automatic fork merging remain non-goals. | +| Design Principles | Previous-policy authorization and append-only projection in [`../src/state.rs`](../src/state.rs); availability/authority separation in [`../src/checkpoint.rs`](../src/checkpoint.rs), [`../src/provider.rs`](../src/provider.rs), and `tests/checkpoint_projection.rs`; privacy and migration in [`../src/privacy.rs`](../src/privacy.rs) and [`../src/crypto_migration.rs`](../src/crypto_migration.rs). | +| High-Level Architecture | Runtime-independent core in [`../src/lib.rs`](../src/lib.rs); pure transition in [`../src/state.rs`](../src/state.rs); effect boundary in [`../src/store.rs`](../src/store.rs) and [`../src/operations.rs`](../src/operations.rs); optional redb/provider/network features in `Cargo.toml`. | +| Identity Hierarchy | `AccountId`, `ControllerId`, `DeviceId`, and application identifiers in [`../src/schema.rs`](../src/schema.rs); derivation-bearing descriptors in [`../src/genesis.rs`](../src/genesis.rs), [`../src/keys.rs`](../src/keys.rs), and [`../src/application.rs`](../src/application.rs); frozen derivation tests in `tests/genesis_schema.rs`, `tests/policy_schema.rs`, and `tests/device_application_schema.rs`. | +| Cryptographic Key Hierarchy | Tagged algorithm/key/signature types in [`../src/types.rs`](../src/types.rs), [`../src/keys.rs`](../src/keys.rs), and [`../src/key_wrap.rs`](../src/key_wrap.rs); controller migration in [`../src/crypto_migration.rs`](../src/crypto_migration.rs); contributory-key and wrapping tests in `tests/vectors.rs` and `tests/key_rotation.rs`. | +| Account-Control Event Log | Canonical event, intent, admission, and final-approval schemas in [`../src/event.rs`](../src/event.rs); 22 operations in [`../src/operations.rs`](../src/operations.rs) and [`../src/types.rs`](../src/types.rs); deterministic application/fork retention in [`../src/state.rs`](../src/state.rs); `tests/account_event_schema.rs`, `tests/event_evidence.rs`, `tests/state_machine.rs`, and `tests/task2_golden_vectors.rs`. | +| Control Policies and Threshold Authorization | Weighted scoped rules in [`../src/policy.rs`](../src/policy.rs); exact pre-state evaluation in [`../src/verifier.rs`](../src/verifier.rs) and [`../src/state.rs`](../src/state.rs); `tests/policy_schema.rs` and `tests/policy_authorization.rs`. | +| Device Authorization and Capabilities | Device records/lifecycle operations in [`../src/device.rs`](../src/device.rs); structural capability and narrowing rules in [`../src/capability.rs`](../src/capability.rs) and [`../src/capability_verifier.rs`](../src/capability_verifier.rs); `tests/device_application_schema.rs`, `tests/capability_schema.rs`, and `tests/capabilities.rs`. | +| Device Pairing Protocol | Typed ceremony, transcript, possession proof, two-party confirmation, SAS, expiry, and nonce-store contract in [`../src/pairing.rs`](../src/pairing.rs); endpoint-owned connection binding in [`../src/net/mod.rs`](../src/net/mod.rs); `src/pairing/tests.rs`, `tests/net_contracts.rs`, and the pairing fuzz target. Direct and local-relay integration are implemented and remain covered by the final current-tree test gate. | +| Revocation Model | Suspend/reinstate/revoke/rotate types in [`../src/device.rs`](../src/device.rs); terminal tombstones, epoch effects, and fork behavior in [`../src/state.rs`](../src/state.rs); application-key write gate in [`../src/store.rs`](../src/store.rs); `tests/state_machine.rs`, `tests/key_rotation.rs`, and `tests/operational_recovery.rs`. | +| Transparency and Availability | Checkpoints, provider heads/receipts/equivocation evidence in [`../src/checkpoint.rs`](../src/checkpoint.rs); Merkle structures in [`../src/merkle.rs`](../src/merkle.rs); provider log and recovery aggregates in [`../src/transparency.rs`](../src/transparency.rs), [`../src/provider.rs`](../src/provider.rs), and [`../src/audit.rs`](../src/audit.rs); bounded interchange in [`../src/provider/interchange.rs`](../src/provider/interchange.rs); publication in [`../src/publication.rs`](../src/publication.rs); transparency, checkpoint, publication, wire-format, audit, and persistence tests. Optional public anchoring is the opaque non-authoritative interface in [`../src/provider/anchor.rs`](../src/provider/anchor.rs). | +| Freshness and Online Status | Presence challenge/proof in [`../src/presence.rs`](../src/presence.rs); monotonic account/caller freshness evaluation with explicit time in [`../src/freshness.rs`](../src/freshness.rs); `tests/presence.rs` and `tests/freshness_decision.rs`. | +| Forks, Concurrency, and Conflict Resolution | Complete predecessor sets, retained branches, deterministic `ForkId`, and explicit resolution in [`../src/state.rs`](../src/state.rs) and [`../src/recovery.rs`](../src/recovery.rs); exact-CAS source store in [`../src/store.rs`](../src/store.rs); fork/order tests in `tests/state_machine.rs`, `tests/store_conformance.rs`, and `tests/checkpoint_projection.rs`. | +| Recovery | Typed begin/veto/cancel/finalize operations and private guardian evidence in [`../src/recovery.rs`](../src/recovery.rs); recovery projection in [`../src/state.rs`](../src/state.rs); encrypted authority/data backup split in [`../src/privacy.rs`](../src/privacy.rs); `tests/recovery_schema.rs`, `tests/recovery_guardians.rs`, `tests/private_backup.rs`, and `tests/operational_recovery.rs`. | +| Social Graph and Attestations | Bounded signed hints, exact authority time, common validity interval, and opt-in transitivity in [`../src/social.rs`](../src/social.rs); encrypted/local relationship policy in [`../src/privacy.rs`](../src/privacy.rs) and the deployment guide; `tests/social.rs`. No attestation implicitly grants control authority. | +| Human-Readable Names and Discovery | Normalized aliases, bounded resolver candidates, signed claims, and explicit TOFU decisions in [`../src/names.rs`](../src/names.rs); `tests/names.rs`. Names remain aliases rather than account identity. | +| Application Data and Group-Key Rotation | Signed application envelope and counter/context verification in [`../src/application.rs`](../src/application.rs); KEM/DEM wrapping and revision-bound rotation in [`../src/key_wrap.rs`](../src/key_wrap.rs); structural authorization in capability modules; `tests/application_verification.rs`, `tests/device_application_schema.rs`, and `tests/key_rotation.rs`. | +| Transport Integration | Frozen six-ALPN registry, authenticated endpoint dispatch, one shared bounded supervisor, direct/local-relay handlers, endpoint-owned pairing binding, and resumable sync in [`../src/transport.rs`](../src/transport.rs), [`../src/net/mod.rs`](../src/net/mod.rs), [`../src/net/protocol.rs`](../src/net/protocol.rs), and [`../src/sync.rs`](../src/sync.rs); `tests/net_contracts.rs`, `tests/sync_contracts.rs`, `tests/store_conformance.rs`, and the opt-in `krikos-app` identity component test provide integration evidence. | +| Protocol Interfaces | Concrete mapping is recorded in the next table. | +| State Machines | Device, account, checkpoint-publication, and operational-effect lifecycle mapping is recorded below. | +| Threat Model | Implemented threat/mitigation and residual-risk statement in [`security-and-deployment.md`](security-and-deployment.md); adversarial tests span signatures, substitutions, rollback/equivocation, replay/forks, bounds, recovery, and persistence. Third-party security review remains an external gate. | +| Privacy Model | Secret/private wrappers and encrypted artifacts in [`../src/privacy.rs`](../src/privacy.rs), [`../src/recovery.rs`](../src/recovery.rs), and [`../src/key_wrap.rs`](../src/key_wrap.rs); redacted/no-public-wire tests in `tests/privacy_boundaries.rs`, `tests/private_artifacts.rs`, and compile-fail rustdoc. | +| Comparison of Ledger Options | The implemented choice is a provider-replicated append-only Merkle log with optional opaque external anchoring, not a mandatory blockchain: [`../src/provider.rs`](../src/provider.rs), [`../src/merkle.rs`](../src/merkle.rs), and [`provider-operations.md`](provider-operations.md). | +| Lessons from Peergos | Reflected in per-device keys, append-only authority, explicit capabilities, provider availability separation, encrypted metadata, and key rotation across the identity hierarchy, event-log, capability, transparency, application-data, and privacy areas above. This is architectural provenance rather than a separate wire object. | +| Serialization and Compatibility | Pinned canonical Postcard v1 profile, codepoints, extension rules, and documented foundational, account-control, synchronization, and network-envelope schemas in [`../README.md`](../README.md); exact provider manifests, chunks, registries, commitment preimages, and bounds in [`provider-operations.md`](provider-operations.md) backed by [`../src/provider/interchange.rs`](../src/provider/interchange.rs), [`../src/provider/compaction.rs`](../src/provider/compaction.rs), and `tests/provider_wire_formats.rs`; bounded canonical re-encode check in [`../src/codec.rs`](../src/codec.rs); migration/upgrade types in [`../src/crypto_migration.rs`](../src/crypto_migration.rs); schema/vector tests and interoperability asset validator are repository gates. | +| Storage and Garbage Collection | Canonical source, journal, fork, checkpoint, and effect retention in [`../src/store.rs`](../src/store.rs); provider generation export/retention/compaction in [`../src/provider.rs`](../src/provider.rs), [`../src/provider/compaction.rs`](../src/provider/compaction.rs), and [`provider-operations.md`](provider-operations.md); provider redb store v7 and audit redb v2 with explicit legacy rejection in [`../src/provider/redb.rs`](../src/provider/redb.rs) and [`../src/audit/redb.rs`](../src/audit/redb.rs); `tests/store_conformance.rs`, `tests/provider_persistence.rs`, provider redb unit tests, and provider fuzzing. | +| Error Model | Typed stable distinctions in [`../src/error.rs`](../src/error.rs), propagated across decoding, verification, projection, storage, networking, and operations. Error taxonomy and no-panic searches are final audit repository gates. | +| API Ergonomics | Validated constructors and explicit state/decision types are re-exported from [`../src/lib.rs`](../src/lib.rs); raw cryptography and wire helpers remain internal. `framework/app/src/identity_protocol.rs`, `framework/app/tests/identity_component.rs`, and `framework/app/examples/identity.rs` implement and exercise the opt-in application component without taking ownership of endpoint-key persistence. | +| Testing Strategy | Complete category-to-command mapping appears below. Deterministic simulation, formal bounded checking, aggregate fuzz smoke, and language-independent vector validation are final repository gates. | +| Transparency Provider Operations | Admission control, rate limits, immutable generations, streaming export/assembly, exact portable preflight, recovery/mirror/compaction, anchoring, constant-size audit CAS, metrics, and incident procedure in [`../src/provider.rs`](../src/provider.rs), [`../src/provider/interchange.rs`](../src/provider/interchange.rs), [`../src/provider/redb.rs`](../src/provider/redb.rs), [`../src/audit.rs`](../src/audit.rs), [`../src/audit/redb.rs`](../src/audit/redb.rs), [`../src/operations.rs`](../src/operations.rs), [`provider-operations.md`](provider-operations.md), and `examples/provider_auditor.rs`. | +| Deployment Profiles | Local-only, consumer, high-security, and enterprise profiles in [`security-and-deployment.md`](security-and-deployment.md). Profiles cannot weaken the account-committed minimum. | +| Implementation Roadmap | Phase mapping appears below. Repository artifacts can complete engineering phases; third-party audit and independent implementation remain external gates. | +| Initial Product Decisions | Frozen v1 choices are recorded in [`../README.md`](../README.md), crate feature boundaries, policy constructors, provider defaults, pairing, capability, freshness, privacy, and key-rotation modules. | +| Security-Critical Invariants | Direct invariant-to-enforcement mapping appears below; deterministic simulation must also check them after every step. | +| Open Design Questions | Resolved v1 choices and genuinely external/open items are recorded in [`security-and-deployment.md`](security-and-deployment.md). Future choices require new protocol versions or explicit authorized migrations, not reinterpretation of v1 bytes. | +| Final Recommendation | Delivered as the managed `krikos-identity` core plus optional storage/provider/network adapters and integration component. Production release remains conditioned on the external gates. | +| References | Normative sources are the crate README and operational documents indexed above. Transport integration uses the workspace `krikos` API; external background references are non-normative and no reference is treated as executable evidence by itself. | + +## Named interface mapping + +The architecture interfaces are realized with narrow pure functions and state-view traits where +dynamic dispatch is unnecessary. This preserves the intended boundary without freezing an +implementation structure as an accidental wire or ABI contract. + +| Architecture interface | v1 repository interface | Evidence | +| --- | --- | --- | +| `AccountStore` | [`AccountStore`](../src/store.rs), `MemoryAccountStore`, and feature-gated `RedbAccountStore`; exact revision CAS commits source event plus outbox atomically and pages checkpoint/source history. | `tests/store_conformance.rs`, `tests/operational_recovery.rs`. | +| `AccountVerifier` | `AccountGenesis::account_id`, `AccountState::from_genesis`, `AccountState::validate_and_apply`, and `verify_checkpoint`; effects are returned rather than executed. | `tests/genesis_schema.rs`, `tests/state_machine.rs`, `tests/checkpoint_projection.rs`. | +| `TransparencyClient` | [`TransparencyClient`](../src/publication.rs), `publish_checkpoint_concurrently`, provider log/store/auditor interfaces. | `tests/publication.rs`, `tests/transparency_crypto.rs`, `tests/provider_persistence.rs`. | +| `FreshnessVerifier` | Pure `evaluate_freshness` over `VerifiedCheckpoint`, signed provider receipts, account requirement, stricter caller requirement, and explicit verifier time. | `tests/freshness_decision.rs`. | +| `CapabilityVerifier` | Pure `evaluate_capability`, `CapabilityStateView`, and `DelegationSignatureVerifier`. | `tests/capabilities.rs`, `tests/application_verification.rs`. | +| Transport/distribution | Discovery, gossip, blob, authenticated endpoint facts, ALPN, bounded framing, shared supervisor, and frozen-revision sync contracts in `transport`, `net`, and `sync`. | `tests/net_contracts.rs`, `tests/sync_contracts.rs`, `tests/store_conformance.rs`, and `framework/app/tests/identity_component.rs`, including direct/local-relay, cancellation, shutdown, backpressure, and cursor-reopen cases. | +| Recovery signer boundaries | `OfflineSigner`, `HardwareController`, `CanonicalSigningRequest`, guardian authority verification, and encrypted backup restoration. | `tests/privacy_boundaries.rs`, `tests/recovery_guardians.rs`, `tests/private_backup.rs`. | +| Name resolver | [`NameResolver`](../src/names.rs) plus bounded cryptographic filtering and explicit TOFU output. | `tests/names.rs`. | +| Provider operations | `ProviderAdmissionControl`, `ProviderStore`, `ProviderAuditStore`, generation/audit/recovery manifest and chunk assemblers, anchoring/compaction interfaces, and durable operational effect traits. | `tests/provider_wire_formats.rs`, `tests/provider_persistence.rs`, provider/audit redb unit suites, operational tests, and provider auditor example. | + +## Lifecycle mapping + +| Identity lifecycle | Representation and transition evidence | +| --- | --- | +| Device `Proposed -> Active` | A `DeviceAuthorizationProposal` is non-authoritative until code 1 creates a validated `DeviceAuthorization`; pairing produces ceremony evidence but does not directly mutate authority. | +| Device `Active <-> Suspended` | Codes 4 and 5; `ProjectedDeviceLifecycle::{Active,Suspended}`; state-machine lifecycle tests. | +| Device rotation | Code 7 atomically terminally revokes the old `DeviceId` and authorizes the independently derived replacement ID. There is no externally visible half-rotated projection. | +| Device `Revoked` | Code 6 and `ProjectedDeviceLifecycle::Revoked`; old IDs/key roles are permanent tombstones. | +| Account `Genesis -> Active` | `AccountGenesis` deterministically constructs the initial `AccountState`; first event uses the genesis-anchor predecessor. | +| Account recovery | `ProjectionLifecycle::RecoveryPending`; begin/veto/cancel/finalize are codes 13--16 and bind the exact prior state, admission, delay, and replacement plan. | +| Account fork | `ProjectionLifecycle::Forked`; all valid branches are retained; code 17 selects one existing branch under common pre-fork authority and late branches reopen a fork. | +| Crypto migration | `ProjectionLifecycle::{MigrationPending,MigrationDual}`; codes 18--20 stage, activate, retire, or abort without suite downgrade/reuse. | +| Protocol upgrade | `ProjectionLifecycle::UpgradePending`; code 21 makes this v1 implementation fail closed/read-only for an authorized future major. | +| Account retirement | `ProjectionLifecycle::Retired`; code 22 is terminal except for identical replay. | +| Checkpoint publication | `PublicationStage::{Draft,Authorized,Published,Replicated,Observed}`; failures remain explicit outcomes and never advance authority. | +| Durable effects | `OperationalEffectPhase` records claim, rotation, checkpoint authorization, publication/observation, notification, retry, terminal failure, and completion across crashes. | + +## Authoritative operation registry + +All operation codepoints are closed in `OperationKind`, encoded by `AccountOperation`, projected in +`AccountState`, documented in the crate README, and frozen by +`tests/task2_golden_vectors.rs::account_operation_vectors_cover_every_v1_code` plus +`tests/state_machine.rs::frozen_epoch_table_covers_every_v1_operation_kind`. + +| Code | Operation | Primary schema/transition module | +| ---: | --- | --- | +| 1 | `AuthorizeDevice` | `device.rs`, `state.rs` | +| 2 | `UpdateDeviceAuthorization` | `device.rs`, `state.rs` | +| 3 | `UpdateDeviceMetadata` | `device.rs`, `state.rs` | +| 4 | `SuspendDevice` | `device.rs`, `state.rs` | +| 5 | `ReinstateDevice` | `device.rs`, `state.rs` | +| 6 | `RevokeDevice` | `device.rs`, `state.rs` | +| 7 | `RotateDeviceKeys` | `device.rs`, `state.rs` | +| 8 | `AddController` | `keys.rs`, `state.rs` | +| 9 | `RemoveController` | `keys.rs`, `state.rs` | +| 10 | `ChangeControlPolicy` | `policy.rs`, `state.rs` | +| 11 | `ChangeRecoveryPolicy` | `policy.rs`, `recovery.rs`, `state.rs` | +| 12 | `ChangeProviderPolicy` | `policy.rs`, `provider.rs`, `state.rs` | +| 13 | `BeginRecovery` | `recovery.rs`, `state.rs` | +| 14 | `VetoRecovery` | `recovery.rs`, `state.rs` | +| 15 | `CancelRecovery` | `recovery.rs`, `state.rs` | +| 16 | `FinalizeRecovery` | `recovery.rs`, `state.rs` | +| 17 | `ResolveFork` | `recovery.rs`, `state.rs` | +| 18 | `BeginCryptoMigration` | `crypto_migration.rs`, `state.rs` | +| 19 | `ActivateCryptoMigration` | `crypto_migration.rs`, `state.rs` | +| 20 | `RetireCryptoSuite` | `crypto_migration.rs`, `state.rs` | +| 21 | `UpgradeProtocol` | `crypto_migration.rs`, `state.rs` | +| 22 | `RetireAccount` | `crypto_migration.rs`, `state.rs` | +| 23 | Reserved `PublishCheckpoint` | Rejected as an authority operation; checkpoint publication is the availability-plane journal in `publication.rs`/`operations.rs`. | + +## Roadmap artifacts + +| Roadmap phase | Repository evidence | Acceptance boundary | +| --- | --- | --- | +| Phase 0: specification foundation | README wire profile/codepoints, canonical codec and types, deterministic state projection, traits, limits, vectors, threat/deployment docs. | Binary-plus-JSON interoperability validator and reference/formal model commands must be green. | +| Phase 1: core multi-device identity | Account/device/controller hierarchy, 22-operation log, policies, pairing core, capabilities, revocation, sync schema, group-key wrapping, six direct/local-relay handlers, and the opt-in app component/example. | Current-tree network, store, and app integration tests must remain green. | +| Phase 2: transparency availability | Checkpoints, Merkle proofs, provider admission/log/store/auditor, publication/freshness, provider example and operations guide. | Full persistent fault/crash and provider fuzz gates must remain green. | +| Phase 3: recovery and advanced control | Weighted policy, recovery lifecycle, guardians, hardware/offline exact signing requests, fork resolution, encrypted authority/application backup. | Recovery durable operational matrix and private-artifact boundaries must remain green. | +| Phase 4: privacy and interoperability | Blinded/private lookup, pairwise IDs, social/name/TOFU, opaque anchoring, portable credentials. | Independent cross-language implementation is an **external gate**; checked-in language-independent assets are the repository prerequisite. | +| Phase 5: hardening | Deterministic simulation/corpus, formal bounded model, aggregate fuzzing, docs, incident/migration playbooks, final audits. | Third-party security audit and independent provider interoperability are **external gates**; stable release must not be claimed before them. | + +## Test-category and command map + +Rust commands use 1.91.0 except bounded fuzzing, which uses the explicitly pinned reviewed nightly +named by the runner and workflows. Exact fuzz duration/execution evidence belongs in the final +verification report so this durable map does not overstate an old run. + +| Verification category | Focused artifacts and command | +| --- | --- | +| Unit/schema/crypto | `cargo +1.91.0 test --locked -p krikos-identity --no-default-features --all-targets`; schema, vectors, policy, Merkle, capability, freshness, presence, recovery, privacy, and application test files. | +| Feature/dependency boundary | `scripts/check-identity-feature-matrix.sh` compiles the reviewed core, singleton integration, integration-combination, and all-feature sets, then checks the no-default normal dependency tree. It invokes `scripts/tests/check-identity-os-rng-boundary.sh` to enforce `krikos-base/key-types`, legacy `key`/`os-rng`, identity `os-rng`, and explicit caller-owned RNG boundaries. | +| Property-based | Proptest histories/narrowing/Merkle cases in `tests/state_machine.rs`, `tests/capabilities.rs`, and `tests/merkle.rs`; also evaluator fuzz selectors. | +| State-machine/reference | `tests/state_machine.rs` plus the independent reference model and simulator replay command. The reference model must not call `AccountState` transition logic. | +| Deterministic distributed simulation | `krikos-sim` recorded scenarios/corpus and replay command covering time, reorder/loss/duplication, partitions, provider faults, crashes/storage loss, recovery, migrations, revocation, and key rotation. | +| Fuzzing | `scripts/run-bounded-fuzz.sh` identity target aggregation; corpus/selector inventory in `scripts/tests/check-fuzz-tooling.sh`; both fuzz CI workflows. Every public canonical decoder and stateful verifier/evaluator must have a selector or a documented non-wire reason. | +| Interoperability vectors | Manifest-v2 JSON metadata and 141 canonical binaries under `tests/vectors/`; the independent read-only validator owns a closed typed/semantic inventory, recursively extracts exact dependencies, derives hash/Merkle outputs, verifies repeatable signature/MAC bindings, and exercises omission/substitution/coordinated-replacement attacks. Two private guardian witnesses and one transient pairing message have explicit dispositions. `scripts/check-identity-interop-vectors.sh` proves deterministic regeneration and corpus reproduction. Independent implementation remains external. | +| Formal methods | Repository-owned bounded command for the model under `docs/identity/`, covering all six properties documented in [`../../../docs/identity/account-control-model.md`](../../../docs/identity/account-control-model.md). A missing external checker is not a pass unless the repository supplies and runs a hermetic equivalent. | +| Provider portability | `cargo +1.91.0 test --locked -p krikos-identity --test provider_wire_formats`; commitment mirror tests in `audit::tests` and `provider::compaction::tests`; exact manifest/chunk schemas, registries, domains, and bounds are indexed in [`provider-operations.md`](provider-operations.md). | +| Persistent operations | `cargo +1.91.0 test --locked -p krikos-identity --features fs-store,provider-store --test store_conformance --test provider_persistence --test operational_recovery`. | +| Provider persistent schemas | `cargo +1.91.0 test --locked -p krikos-identity --features provider-store --lib provider::redb::tests -- --test-threads=1` and the corresponding `audit::redb::tests` command; covers provider store v7, audit store v2, explicit legacy rejection, prepared reopen/preflight, atomic CAS, and bounded hot-path canaries. | +| Network integration | Feature-gated two-node direct/local-relay pairing, proposal, and resumable sync tests plus bounded-frame/backpressure/cancel/shutdown cases. | +| Documentation/API | `RUSTDOCFLAGS='-Dwarnings' cargo +1.91.0 doc --locked -p krikos-identity --all-features --no-deps` and locked no-default/all-feature doctests. | +| Workspace/release | Workspace tests/Clippy/format, architecture/hermetic/package/release/reservation scripts, `git diff --check`, unsafe/panic/allocation audit, and final evidence report. | + +## Security-critical invariant map + +| Invariant | Enforcement and direct regression evidence | +| --- | --- | +| The account is not a device. | Separate self-certifying types/descriptors and endpoint dispatch; `tests/policy_schema.rs`, `tests/state_machine.rs`, `tests/net_contracts.rs`. | +| No ordinary account private key is copied to every device. | Public account state contains controller/device public descriptors only; per-device signing/agreement/endpoint roles and redacted secrets; key-role separation tests. Human custody is outside the crate and documented. | +| Every device is independently identifiable, authorizable, and revocable. | `DeviceId` commits all device public keys; codes 1--7; device tombstones; device/application schema and lifecycle tests. | +| Every account-control transition is signed under the previous policy. | `EventBody` predecessor set, intent/admission/final approval binding, pre-state policy evaluator; policy authorization and state-machine mutation-on-error tests. | +| Account identity survives complete device/controller rotation. | `AccountId` hashes immutable genesis; replacement IDs and controller/migration transitions do not rewrite genesis; genesis/state/migration tests. | +| Providers distribute state but cannot create it. | Provider admission requires exact pre-state-approved intent; checkpoints are independently account-authorized; provider log/persistence/projection tests. | +| Social relationships grant no implicit account authority. | Social module returns bounded hints only; no social type enters the control evaluator except the explicit private guardian recovery policy path; social/recovery tests. | +| Revocation becomes externally discoverable only after durable publication. | Projection emits durable effect; operational phases distinguish local authorization, provider publication, replication, and observation; publication/operational crash tests. | +| Offline validity is relative to known state, not globally current. | `AuthorizationContext`, checkpoint/epoch basis, explicit `FreshnessDecision`, and provider evidence/time requirements; capability/application/freshness tests. | +| Sensitive actions fail closed without required freshness or consistency. | Monotonic caller/account requirements, exact provider quorum/time, fork lifecycle gates, and `FreshnessUnavailable`; freshness/policy/application tests. | +| A removed device receives no future group keys. | Rotation snapshot derives exact active membership at revision; store blocks protected writes until required rotation commits; key-rotation and operational-recovery tests. | +| Conflicting security histories are detected and explicitly resolved. | Complete predecessor/head sets, distinct admitted `EventId`s, retained fork evidence, code 17 exact branch selection, and late-fork reopening tests. | + +## External release gates + +The machine-readable [`../release-gate.toml`](../release-gate.toml) and its +[`release-gate.md`](release-gate.md) checklist block stable publication. The following are +intentionally **not** claimed complete by repository tests: + +1. `third_party_security_audit`: a named independent cryptographic and protocol audit of the stable + candidate, with critical/high findings resolved or explicitly release-blocked. +2. `independently_maintained_interoperability`: a separately maintained implementation that + validates the public v1 fixtures and negative cases and completes the required ceremonies in both + directions. +3. `production_provider_diversity`: production evidence of the stable provider quorum across + independently administered infrastructure and failure domains, including a provider-loss drill. +4. `protocol_governance`: a published process for wire changes, codepoint allocation, compatibility + windows, vulnerability handling, and protocol-upgrade decisions. +5. `public_api_semver_baseline`: an immutable reviewed public API/rustdoc baseline, SemVer policy, + compatibility result, and stable-crate release ownership. +6. `persistent_schema_support`: documented account/provider schema support windows, + forward/rollback rules, migration fixtures, backup/restore drills, and operational ownership. + +Platform-specific secure-hardware UX, attestation, backup, and signing-display normalization also +remain external deployment work, but they are not one of the six machine-readable release +approvals. + +The checked-in vectors, bounded model, fuzz/simulation corpus, operational playbooks, and final +verification matrix are prerequisites for those gates, not replacements for them. Until every +approval has qualifying evidence, `python3 scripts/check-identity-release-gate.py --expect-closed` +must pass and `--require-open` must fail. diff --git a/protocols/krikos-identity/docs/provider-operations.md b/protocols/krikos-identity/docs/provider-operations.md new file mode 100644 index 00000000000..732eca893ef --- /dev/null +++ b/protocols/krikos-identity/docs/provider-operations.md @@ -0,0 +1,507 @@ +# Provider persistence and operations + +The default `krikos-identity` feature set remains deterministic and database-free. Enable +`provider-store` to use the redb-backed provider generation, audit journal, and operational-effect +journal. `fs-store` independently enables the redb account source/checkpoint store. Production +operators normally enable both. + +This runbook is the normative provider-portability appendix for v1. It fixes the portable +manifest/chunk schemas, provider-only registries, commitment preimages, and resource ceilings below. +The redb layouts are separately versioned implementation formats, not interchange formats. Completing +this repository appendix does not open the six external approvals in +[`release-gate.md`](release-gate.md). + +## Provider portability profile v1 + +All structures in this section use the Postcard 1.1.3 rules in the crate +[`README`](../README.md). Field lists are in serialization order. `sequence` is a Postcard +sequence with at most `N` items, `bytes` is a Postcard byte string with at most `N` bytes, and +`option` is Postcard's closed option. Every `format_version` in this appendix is `1`; another +value fails closed. Provider portability never serializes `usize`, maps, floats, or a Rust enum +ordinal. + +`ProviderGenerationExport`, `ProviderAuditSnapshot`, and `ProviderRecoveryExport` are validated +aggregate API types rather than single unbounded wire messages. Their portable representations are, +respectively, a generation manifest plus generation chunks, an audit manifest plus audit chunks, +and a recovery manifest plus both committed chunk sets. Assemblers may receive chunks out of order; +an exact duplicate is idempotent, while a conflicting ordinal, missing range, overlap, gap, route +mismatch, count mismatch, byte mismatch, or commitment mismatch fails closed. + +### Exact portable schemas + +The generation components carry these exact item forms: + +| Component | Canonical item | +| --- | --- | +| `Entries` | `ProviderLogEntryBody` canonical bytes | +| `LeafHashes` | `Digest` canonical bytes | +| `Receipts` | `InclusionReceipt` canonical bytes | +| `CheckpointBundles` | `(format_version: u16, bundle: (genesis: option, prior_checkpoint_id: option, events: sequence, checkpoint: SignedCheckpoint, transition_event: option))` | +| `CompactionManifests` | `ProviderCompactionManifest` canonical bytes | + +An audit item is `(format_version: u16, record: (sequence: u64, head: SignedProviderHead, +consistency_proof: option, status_code: u16))`. A chunk `payload` is the +canonical encoding of `sequence, 256>`; `item_payload_bytes` is the sum of the +inner canonical item lengths and deliberately excludes the sequence and byte-string length prefixes. + +The validated aggregate API shapes, which are committed and split into the bounded messages below +rather than encoded as one interchange frame, are: + +| Aggregate | Exact logical field order and types | +| --- | --- | +| `ProviderGenerationExport` | `(provider: ProviderDescriptor, log_id: ProviderLogId, key_version: ProviderKeyVersion, entries: sequence, leaf_hashes: sequence, latest_head: option, receipts: sequence, checkpoint_bundles: sequence, compaction_manifests: sequence)` | +| `ProviderAuditSnapshot` | `(revision: u64, provider: ProviderDescriptor, log_id: ProviderLogId, latest_head: option, equivocation: option, records: sequence)` | +| `ProviderAuditRecord` | `(sequence: u64, head: SignedProviderHead, consistency_proof: option, status: ProviderAuditStatus)`; portable items and commitments replace `status` with its `status_code: u16` below | +| `ProviderAuditArtifact` | `(sequence: u64, kind: ProviderAuditArtifactKind, accepted_head: SignedProviderHead, observed_head: SignedProviderHead)`; commitments replace `kind` with its `kind_code: u16` below | +| `ProviderRecoveryExport` | `(generation: ProviderGenerationExport, audit: ProviderAuditSnapshot, artifacts: sequence, generation_commitment: Digest, audit_commitment: Digest, artifact_commitment: Digest, recovery_commitment: Digest)` | +| `ProviderRetentionItem` | `(leaf_index: u64, class: ProviderRetentionClass)`; inventory commitments replace `class` with its `class_code: u16` below | +| `ProviderRetentionInventory` | `(tree_size: u64, items: sorted unique sequence, audit_artifacts: sorted unique sequence)`; `tree_size <= 1048576`, item order is `(leaf_index, class_code)`, artifact order is `(sequence, kind_code)`, and the pair ceiling follows from the eight closed classes | + +| Structure | Exact field order and types | +| --- | --- | +| `ProviderExportComponent` | `(format_version: u16, component_code: u16)` | +| `ProviderExportComponentDescriptor` | `(format_version: u16, component_code: u16, item_count: u64, chunk_count: u32, total_payload_bytes: u64, chunk_list_commitment: Digest)` | +| `ProviderGenerationExportChunk` | `(format_version: u16, provider_id: ProviderId, log_id: ProviderLogId, key_version: ProviderKeyVersion, generation_commitment: Digest, component_code: u16, ordinal: u32, start_index: u64, end_index: u64, item_payload_bytes: u64, payload: bytes<4 MiB - 2 KiB>)` | +| `ProviderAuditExportChunk` | `(format_version: u16, provider_id: ProviderId, log_id: ProviderLogId, audit_commitment: Digest, ordinal: u32, start_sequence: u64, end_sequence: u64, item_payload_bytes: u64, payload: bytes<4 MiB - 2 KiB>)` | +| `ProviderGenerationExportManifest` | `(format_version: u16, provider: ProviderDescriptor, log_id: ProviderLogId, key_version: ProviderKeyVersion, tree_size: u64, tree_root: Digest, latest_head: option, generation_commitment: Digest, total_payload_bytes: u64, components: sequence)` | +| `ProviderAuditExportManifest` | `(format_version: u16, provider: ProviderDescriptor, log_id: ProviderLogId, latest_head: option, equivocation: option, record_count: u64, chunk_count: u32, total_payload_bytes: u64, audit_commitment: Digest, artifact_count: u64, artifact_commitment: Digest, chunk_list_commitment: Digest)` | +| `ProviderRecoveryExportManifest` | `(format_version: u16, generation: ProviderGenerationExportManifest, audit: ProviderAuditExportManifest, generation_manifest_commitment: Digest, audit_manifest_commitment: Digest, generation_commitment: Digest, audit_commitment: Digest, artifact_commitment: Digest, recovery_commitment: Digest)` | +| `ProviderCompactionManifest` | `(format_version: u16, provider_id: ProviderId, log_id: ProviderLogId, key_version: ProviderKeyVersion, source_tree_size: u64, source_tree_root: Digest, archive_commitment: Digest, generation_commitment: Digest, audit_commitment: Digest, audit_artifact_commitment: Digest, inventory_commitment: Digest, retained_evidence_commitment: Digest, retained_ranges: sequence)` | +| `ProviderRetainedRange` | `(start: u64, end_exclusive: u64)` | +| `OpaqueProviderAnchorCommitment` | `(format_version: u16, commitment: [u8; 32])` | + +Generation chunk offsets are zero-based half-open ranges. Audit sequences are one-based and audit +chunk ranges are half-open. Generation manifests contain exactly five descriptors in ascending +component-code order. Entry, leaf-hash, and receipt counts equal `tree_size`; checkpoint-bundle +count cannot exceed it. A recovery manifest requires the same provider, log, and latest head in its +generation and audit manifests, and binds both manifest commitments as well as all three semantic +aggregate commitments. + +For each generation component, zero items requires zero chunks, zero payload bytes, and the exact +empty ordered-list commitment. A nonempty component requires +`ceil(item_count / 256) <= chunk_count <= item_count`; the sum across the five component streams is +at most 65,536 chunks. The audit manifest applies the same empty/nonempty and minimum-chunk rules to +its record stream. A nonempty audit manifest requires a latest head and nonzero payload bytes, +requires `artifact_count <= record_count`, and requires at least one derived artifact when terminal +equivocation is present. Its empty form has no head or equivocation and commits the canonical empty +snapshot, empty artifact list, and empty audit chunk list. + +Rollback/equivocation artifacts are deterministically reconstructed from the authenticated audit +records; there is no separate caller-selected artifact chunk stream. `ProviderRetentionInventory` +is likewise a validated semantic commitment input rather than an unbounded standalone interchange +message. Its exact preimage and limits are fixed below. + +### Closed provider registries + +| Registry | Code | Meaning | +| --- | ---: | --- | +| Provider export component | 1 | `Entries` | +| | 2 | `LeafHashes` | +| | 3 | `Receipts` | +| | 4 | `CheckpointBundles` | +| | 5 | `CompactionManifests` | +| Provider audit status | 1 | accepted, `FirstObserved` | +| | 2 | accepted, `TreeAdvanced` | +| | 3 | accepted, `HeadRefreshed` | +| | 4 | accepted, exact `Replay` | +| | 5 | authenticated `Rollback` | +| | 6 | authenticated `Equivocation` | +| Provider audit artifact kind | 1 | `Rollback` | +| | 2 | `Equivocation` | +| Provider retention class | 1 | `CheckpointLineage` | +| | 2 | `ControllerTombstone` | +| | 3 | `DeviceTombstone` | +| | 4 | `UnresolvedFork` | +| | 5 | `CryptoMigration` | +| | 6 | `Recovery` | +| | 7 | `ProviderRotation` | +| | 8 | `Equivocation` | + +All unlisted values are invalid. `ProviderAnchorStatus` is an adapter return type and has no +portable codepoint registry. + +### Commitment rule and exact preimages + +The foundational provider objects use the same `domain || NUL || canonical bytes` construction +already defined by the crate profile: + +| Domain | Active v1 use | +| --- | --- | +| `KRIKOS-ID/provider/v1` | `ProviderId` is BLAKE3-256 over the canonical `ProviderDescriptor` | +| `KRIKOS-ID/provider-policy/v1` | `ProviderPolicyId` is BLAKE3-256 over the canonical `ProviderPolicy` | +| `KRIKOS-ID/provider-log-entry/v1` | one provider Merkle leaf digest is BLAKE3-256 over the canonical `ProviderLogEntryBody` | +| `KRIKOS-ID/provider-head-signature/v1` | the provider signs `domain || 0x00 || canonical(ProviderHeadBody)` directly; this row is a signature message, not another hash | +| `KRIKOS-ID/merkle-node/v1` | an interior provider-log node is BLAKE3-256 over canonical `(left: Digest, right: Digest)` | +| `KRIKOS-ID/merkle-empty/v1` | the empty provider-log root is BLAKE3-256 over the empty payload | + +The hash-domain registry also reserves `KRIKOS-ID/provider-log/v1` and +`KRIKOS-ID/provider-head/v1`, but no current production path derives a `ProviderLogId` or provider +head digest with those generic domains. Callers supply the explicit log ID and providers sign the +literal head-signature message above. The reserved domains must not be substituted for an active +v1 commitment. `KRIKOS-ID/anchor/v1` is also a reserved generic hash domain; the active opaque +provider anchor uses only `KRIKOS-ID/provider-anchor-commitment/v1` below. + +Every commitment in the table below is BLAKE3-256 over +`ASCII(domain) || 0x00 || Postcard(preimage)`. Every domain is ASCII and ends in `/v1`; every +top-level preimage begins with `format_version: u16 = 1`. The implementation streams Postcard bytes +directly into BLAKE3 for large aggregates, which is byte-for-byte equivalent to encoding the whole +preimage first. A `Digest` result is tagged `Blake3_256`; the prepared-owner token and opaque anchor +store the same 32 hash bytes without an algorithm field at their immediate API boundary. + +| Domain | Exact Postcard preimage after the NUL separator | +| --- | --- | +| `KRIKOS-ID/provider-generation-chunk/v1` | `(format_version, provider_id, log_id, key_version, generation_commitment, component_code, ordinal, start_index, end_index, item_payload_bytes, payload)` | +| `KRIKOS-ID/provider-generation-chunk-list/v1` | `(format_version, component_code, chunk_count, commitments)` in ordinal order | +| `KRIKOS-ID/provider-generation-manifest/v1` | the complete `ProviderGenerationExportManifest` schema above | +| `KRIKOS-ID/provider-audit-chunk/v1` | `(format_version, provider_id, log_id, audit_commitment, ordinal, start_sequence, end_sequence, item_payload_bytes, payload)` | +| `KRIKOS-ID/provider-audit-chunk-list/v1` | `(format_version, component_code = 0, chunk_count, commitments)` in ordinal order | +| `KRIKOS-ID/provider-audit-manifest/v1` | the complete `ProviderAuditExportManifest` schema above | +| `KRIKOS-ID/provider-recovery-manifest/v1` | the complete `ProviderRecoveryExportManifest` schema above | +| `KRIKOS-ID/provider-generation-export/v1` | `(format_version, provider, log_id, key_version, entries, leaf_hashes, latest_head, receipts, checkpoint_bundles, compaction_manifests)`; each checkpoint bundle is `(genesis, prior_checkpoint_id, events, checkpoint, transition_event)` | +| `KRIKOS-ID/provider-audit-artifact/v1` | `(format_version, sequence, kind_code, accepted_head, observed_head)` | +| `KRIKOS-ID/provider-audit-snapshot/v1` | `(format_version, revision, provider, log_id, latest_head, equivocation, records)`; each record is `(sequence, head, consistency_proof, status_code)` | +| `KRIKOS-ID/provider-audit-artifacts/v1` | `(format_version, artifact_commitments)` sorted by `(sequence, kind_code)` | +| `KRIKOS-ID/provider-recovery-export/v1` | `(format_version, generation_commitment, audit_commitment, artifact_commitment)` | +| `KRIKOS-ID/provider-retention-inventory/v1` | `(format_version, tree_size, items, audit_artifact_commitments)`; each item is `(leaf_index, class_code)` and artifacts are sorted by `(sequence, kind_code)` | +| `KRIKOS-ID/provider-retained-evidence/v1` | `(format_version, records, checkpoint_evidence, checkpoint_index, audit_artifact_commitment)`; a record is `(leaf_index, entry, receipt)`, checkpoint evidence is `(genesis, prior_checkpoint_id, events, checkpoint, transition_event)`, and an index is `(account_id, greatest_sequence, greatest_epoch, current_checkpoint_id, projection_heads, forked)` | +| `KRIKOS-ID/provider-anchor-commitment/v1` | `(format_version, manifest: ProviderCompactionManifest)` including every manifest field and retained range | +| `KRIKOS-ID/provider-prepared-owner/v1` | `(format_version, base_root, base_size, material, leaf_index, observed_at)` where `material` is the exact prepared generation material defined below | + +For the prepared-owner preimage, `material` is `(entries, leaf_hashes, account_index, frontier, +nodes, checkpoint_bundles, checkpoint_index)` in that order. An account-index item is +`(account_id, leaf_indices)`, a frontier item is `(level: u8, root)`, a Merkle-node item is +`(start: u64, size: u64, root)`, and a checkpoint-index item is `(account_id, greatest_sequence, +greatest_epoch, current_checkpoint_id, projection_heads, forked)`. Checkpoint bundles use the exact +five-field bundle schema above. This is an authenticated internal v7 prepare format, not a public +interchange message. + +The generation-export commitment covers the export state immediately before a newly authorized +compaction manifest is inserted, avoiding self-reference. Every later generation commitment covers +all already-durable manifests. The recovery commitment binds the complete generation, complete +audit journal, and the sorted derived attack artifacts; it is not a commitment to availability +alone. + +### Portable and persistence bounds + +| Resource | V1 ceiling | +| --- | ---: | +| Canonical generation or audit chunk | 4 MiB | +| Decoded items in one chunk | 256 | +| One unsplit canonical item | 4 MiB - 4 KiB | +| Encoded chunk payload | 4 MiB - 2 KiB | +| Generation or audit manifest | 64 KiB | +| Recovery manifest | 128 KiB | +| Total chunks in one generation manifest; audit chunks in one audit manifest | 65,536 | +| Entries, leaf hashes, receipts, or checkpoint bundles in the portable generation profile | 1,048,576 per component | +| Compaction manifests in one generation | 256 | +| Aggregate canonical generation item bytes | 512 MiB | +| Audit records / aggregate canonical audit item bytes | 65,536 / 256 MiB | +| Checkpoint-lineage events in one checkpoint-bundle item | 256 | +| Compaction manifest canonical bytes / retained ranges | 2 MiB / 4,096 | +| Retention inventory pairs / addressable leaves / non-leaf audit artifacts | `8 * tree_size` unique pairs / 1,048,576 leaves / 65,536 artifacts | +| Opaque anchor commitment canonical bytes / backend evidence | 64 bytes / 16 KiB | +| One redb provider generation / one committed or prepared blob | 65,536 entries / 512 MiB | +| Stored account-index records / leaf indices in one record | 65,536 / 65,536 | +| Stored checkpoint bundles / checkpoint-index records / projection heads in one index | 65,536 / 65,536 / 16 | +| Stored sealed retention-inventory pairs | 65,536 | +| Stored Merkle nodes / frontier nodes | 131,072 / 64 | +| Stored embedded provider audit bytes | 256 MiB | + +All item, count, and aggregate byte arithmetic is checked. Active stores maintain exact +per-component canonical-byte accounting on append and replacement; active and complete-archive +redb stores reconstruct it authoritatively on restore or open. A locally sealed generation has no +complete portable export, so reopen installs an explicit empty read-only sentinel instead. Every +mutation path rejects that sealed payload with `ProviderArchiveRequired` before consulting the +sentinel. Thus a mutable generation that fits its native database but cannot be represented by the +portable profile is rejected before it can be committed, without pretending a compacted local +view is a complete export. + +## Generation boundary + +Each `RedbProviderStore` path owns exactly one `(ProviderDescriptor, ProviderLogId, +ProviderKeyVersion)` generation. Opening a path with a different tuple fails; the adapter never +rolls a log or signing key implicitly. Version 1 currently verifies only `ProviderKeyVersion::GENESIS`. +A key change therefore requires an account-authorized `ChangeProviderPolicy` transition, a new +descriptor/provider ID, a new log generation, and a new store path. An in-place key-version bump is +rejected. Retain the old path, its signed heads, export, compaction manifests, and audit evidence. +After restart, select a checkpoint only from the exact current account-authorized policy and +descriptor generation. A longer log from an old or forked generation never wins by length. + +Use `ProviderGenerationRegistry` when more than one generation is open. Every lookup supplies an +exact `ProviderGenerationRoute` containing provider ID, log ID, and key version. Policy routing +also checks that the exact provider ID occurs in that policy revision. The registry has no +`current`, `latest`, or longest-log fallback, and it rejects a second store at an already registered +route instead of replacing the first one. A locally sealed generation and its immutable full +archive therefore remain explicit alternatives at the same address; the caller chooses which one +to register for the operation being performed. + +An append has four durable phases: + +1. A native redb transaction authenticates the committed generation and durably records a hidden + prepared candidate containing the entry, leaf hashes, account index, append frontier, and nodes. +2. A second transaction derives and records the exact `Signing` head body before invoking the + configured signer. A signing error retains that same bound body for `resume_append`; it never + substitutes a new request after the signer may have observed the message. +3. The verified signer result is persisted as the exact `Signed` candidate before it becomes + visible. +4. A final transaction compares the exact base root/size, commits the candidate entry, + index, nodes, signed head, and inclusion receipt together, and removes the prepare record. + +Only the final transaction changes public tree size. Reopen retains `Prepared` and `Signing` +candidates, authenticates every derived field, and promotes a durably `Signed` candidate without +calling the signer again. `cancel_prepared_append` can remove only a never-signed `Prepared` +candidate. Do not wrap the adapter in a process mutex as a substitute for redb transactions. +Concurrent callers may receive `ResourceBusy` while a durable candidate is owned and should use +the bounded retry policy. + +Provider append authority is the opaque token returned by checkpoint or intent verification. +Abuse controls receive that token only so they can deny capacity; they cannot manufacture it. +Preserve `ProviderUnavailable`, `ProviderTimeout`, `ProviderRateLimited`, `InvalidProof`, +`ProviderRollback`, and `ProviderEquivocation` as distinct outcomes. + +Admission capacity is charged from the exact canonical request bytes and rejects any request above +4 MiB before storage mutation. Checkpoint admissions retain the complete verified checkpoint bundle +needed to rebuild the fork-safe account index; intent admissions retain the exact approved intent. +Because `CheckpointId` commits only the checkpoint body, a duplicate leaf may carry another +independently valid controller-approval subset for the same exact lineage. The store merges those +approvals canonically in either arrival order without adding a leaf; a different lineage, +transition witness, or checkpoint body still fails closed. +The provider serves inclusion proofs for committed leaves, exact arbitrary-prefix consistency +proofs, and bounded account history. A current checkpoint is returned only when all currently known +heads for the account resolve to one exact authorized bundle; unresolved or asymmetric forks have +no synthetic winner. + +## Persistent schema and crash boundary + +### Provider generation store v7 + +The redb generation layout is store version `7`. The committed table is +`krikos-provider-generation-v1` at key `active`; the transient append table is +`krikos-provider-prepared-v1` at key `append`. The table names identify their original table +families, while the `StoredProviderWire.version` and `PreparedAppendWire.version` fields select the +current layout. Only version `7` is accepted. Version `6`, any other version, and malformed or +oversized stored values are explicit `StorageCorruption`; this adapter performs no implicit +migration or downgrade. Back up and migrate a previous store with tooling that names both schema +versions rather than opening it as v7. + +The logical v7 prepare record is `(version, owner_token: [u8; 32], base_tree_size, +base_tree_root, requested_observed_at, leaf_index, material, stage)`. `stage` is exactly one of +`Prepared`, `Signing { body }`, or `Signed { head }`. It is a private store-versioned enum, not a +portable codepoint registry; changing its encoding requires another store version. + +Before the `Prepared` candidate is written, the adapter performs exact portable preflight against +the authenticated cached accounting. A new leaf must be exactly one entry, one parallel leaf hash, +one exact placeholder receipt, and at most one appended checkpoint bundle; a duplicate-checkpoint +approval merge may replace exactly one receipt and at most one bundle without changing the entry or +leaf streams. The placeholder has the exact canonical receipt size because it uses the fixed-width +Ed25519 signature schema; it grants no authority and is replaced by the verified signer result. +Every count, item byte length, component total, and 512 MiB aggregate delta is checked before the +prepared transaction commits. + +The prepared owner token is the exact `provider-prepared-owner/v1` commitment specified above. On +every `Prepared -> Signing -> Signed` replacement and on reopen, the adapter rechecks that token, +store version, base root and size, generation route, derived material and indices, bound head body, +signature when present, and the exact portable delta. A reopened `Prepared` or `Signing` candidate +remains pending; a reopened `Signed` candidate is promoted without calling the signer again. + +Failure atomicity is at the redb transaction boundary: + +- preflight, signer-body, signature, receipt, or base-CAS failure cannot change the committed tree; +- each prepared-stage replacement compares the exact retained candidate before replacing it; +- final promotion writes the complete committed state and removes the prepared value in one + transaction; and +- in-memory portable accounting changes only after the redb commit succeeds. + +A never-signed `Prepared` candidate may be cancelled. Once the exact head body reaches `Signing`, +the candidate may only be resumed with that body or promoted from its already verified `Signed` +state. + +### Provider audit store v2 + +The normalized audit layout is version `2`: + +| redb object | Exact layout | +| --- | --- | +| `krikos-provider-audit-metadata-v2`, key `journal` | `(version = 2, revision, provider, log_id, latest_head, equivocation)`; at most 64 KiB | +| `krikos-provider-audit-records-v2`, key `sequence: u64` | `(sequence, head, consistency_proof, status_code)`; at most 4 MiB per record | + +The old monolithic table `krikos-provider-audit-v1` is detected and rejected as +`StorageCorruption`; there is no automatic v1-to-v2 migration. When creating a journal, a missing +metadata row is valid only if the record table is empty. Open, reopen, explicit snapshot load, and +export reconstruct the complete journal and require exactly the contiguous keys `1..=revision`, no +record beyond the declared revision, no gaps, no key/body sequence mismatch, at most 65,536 records, +valid status codepoints, valid provider signatures and consistency outcomes, and exact agreement +between metadata and the replayed latest head/equivocation state. + +The observation hot path is constant-size with respect to prior journal length. `load_cursor` +returns only revision, provider, log ID, latest accepted head, and terminal equivocation evidence. +`compare_and_append` validates one exact successor and one portable audit-item delta, compares the +durable metadata revision, then inserts that single sequence record and updates metadata in one redb +transaction. A stale cross-instance CAS refreshes authoritative state and retries under the bounded +auditor policy. No partial record survives a failed CAS or failed commit, and the cache is updated +only after commit. Full historical replay remains mandatory at open/reopen and explicit full-load +boundaries, not on each observation. + +## Bounds and recovery + +- one redb provider generation: 65,536 operational entries (rotate explicitly before the cap); +- protocol Merkle generation: 1,048,576 leaves; +- account-history response: 256 records and 4 MiB; +- provider count per publication: 16; +- effect/audit retry budget: 8; +- operational audit markers per effect: 256; +- owned identity queue: 256; concurrent tasks: 64; graceful shutdown: 10 seconds. + +### Active, locally sealed, and archived generations + +`ProviderRecoveryExport` is the unit of full mirror and recovery. It binds the exact +`ProviderGenerationExport`, the complete `ProviderAuditSnapshot` for the same authenticated latest +head, sorted rollback/equivocation artifacts, and separate generation, audit, artifact, and +composite recovery commitments. Construct it with `export_recovery` or +`ProviderRecoveryExport::new`; a mismatched provider, log, head, journal, artifact, signature, +receipt, checkpoint authorization, or commitment fails validation. + +Derive the mandatory compaction inventory with +`derive_provider_retention_inventory(&recovery_export)`. A caller may retain additional valid +leaf/class pairs, but cannot omit or relabel a mandatory pair; its non-leaf audit-artifact set must +equal the derived sorted set. `record_compaction_manifest` and `seal_after_verified_mirror` accept +only the exact inventory committed by an authorization produced from an exact source/mirror +comparison. The source and mirror recovery exports, including their audit records and artifacts, +must be equal. + +Each manifest requires BLAKE3-256 for its source root and six component/aggregate commitments, +requires `archive_commitment = recovery_commitment(generation, audit, audit artifacts)`, and binds +the source route, root and size, original-index retained ranges, inventory, and retained records, +checkpoint evidence, checkpoint index, and artifacts. Retained ranges are sorted, non-overlapping, +non-empty half-open intervals within the source tree. The candidate manifest commits the export +state immediately before that manifest is inserted, avoiding self-reference; later manifests +commit all earlier manifests. Memory and redb retain at most 256 manifests, and redb records +sealing atomically. + +Local sealing is irreversible and semantic. It preserves current checkpoint tips, every branch of +an unresolved fork, destructive tombstone/recovery/migration/provider-rotation material, mandatory +original-index receipts, and non-leaf rollback/equivocation audit artifacts. Benign superseded +history and full replay bundles are released locally. The locally sealed store is read-only: +append, resume, cancel, and further archive-style sealing return `ProviderArchiveRequired`. +Deep validated reopen authenticates the retained state and installs a never-consulted read-only +portable-accounting sentinel rather than attempting to reconstruct the deliberately unavailable +full export. Append and compaction-manifest recording reject the sealed state before portable-delta +preflight; an unexpected prepared record beside a sealed state is `StorageCorruption`. + +A locally sealed store exposes `ProviderRetainedCheckpointEvidence` only for retained current +links. This is raw, structurally checked material: genesis or prior checkpoint ID, bounded events, +signed checkpoint, optional transition witness, and its original provider receipt. It is not a +`VerifiedCheckpoint`, cannot create a provider admission, and does not make the compaction manifest +an account-authority signature. Replay it with +`build_provider_checkpoint_bundle_from_genesis` or +`build_provider_checkpoint_bundle_from_prior` from independently trusted state. If the required +prior state was released, retrieve the full recovery archive. Full history, checkpoint bundles, +lineage pages, and generation export deliberately return `ProviderArchiveRequired` on the local +sealed store. + +Install the full immutable copy with `MemoryProviderStore::restore_recovery` or +`RedbProviderStore::restore_recovery` at a dedicated archive path. An archive retains exact full +history, checkpoint replay bundles, audit journal, and audit artifacts. Exact repeated restore is +idempotent; any different existing generation is rejected without overwrite. Archives serve full +read APIs but reject append, resume, cancel, compaction-manifest recording, and sealing. This keeps +their exact recovery export immutable, including across redb reopen and repeated restore. Raw +evidence reconstructed from a local sealed generation cannot mutate an archive, and the exact-route +registry prevents it from being silently installed as a duplicate generation. + +On corruption, stop writes and preserve the database bytes. Restore the exact composite recovery +export at a new archive path, verify its root, head, history, checkpoint replay, audit commitment, +and artifacts, and compare the old and mirror heads with the auditor. Reopen validation recomputes +entry and receipt bindings, leaf hashes, frontier/nodes, account index, head signature, key/log +address, manifest commitments/ranges/inventory, and sealed/archive state kind. A truncated or +corrupt component is `StorageCorruption`; do not normalize it into an empty log or automatically +roll back to an older head. + +## Auditor + +`DurableProviderAuditor` journals accepted heads and authenticated rollback/equivocation outcomes +with compare-and-swap revisions. Same-size/different-root evidence becomes terminal and survives +redb reopen. The example accepts bounded canonical files: + +```text +cargo run -p krikos-identity --example provider_auditor -- \ + provider.bin older-head.bin newer-head.bin consistency-proof.bin evidence.bin +``` + +Auditors must compare heads obtained from independent peers or mirrors; neither provider is trusted. +For a same-size conflict the consistency proof is ignored, but the argument position remains +reserved when an evidence output path is supplied. + +## Effect execution and protected writes + +`OperationalEffectJournal` keys every substep by the account operation's stable `EffectId`. It +retains deterministic checkpoint build/authorization, the exact authorizing `ProviderPolicy`, exact +provider receipts and observation proofs, rotation completion, peer notification, retry, and +terminal audit phases. +`RedbOperationalEffectStore` survives every process restart. Same-phase progress is still a durable +mutation: each newly accepted receipt or observation is revisioned and audited even when it does not +cross a threshold. Reopen re-verifies the policy ID, configured descriptors, signatures, inclusion +proofs, consistency proofs, and threshold-derived stage instead of trusting the serialized phase. + +Re-run the same step after a crash. Account checkpoint commits, provider receipts, rotations, +operation completion, and journal transitions are idempotent under the same stable effect ID. After +a retry, claim the effect under a new bounded lease/attempt and call `begin` again; the journal +retains authenticated checkpoint, receipt, observation, rotation, and notification substep progress +while binding the new lease. Terminal journal states are immutable except for an exact idempotent +replay. If operation completion commits before the journal transition, `complete_ready_effect` +reconciles the already-completed outbox record on reopen. + +Replicated publication completion requires the `Observed` phase. A partial batch remains +`Authorized`, `Published`, or `Replicated` exactly as computed by `PublicationTracker`; threshold +shortfall is retryable and must never be relabelled. `LocalOnly` is the explicit exception: it +completes directly from policy-bound `Authorized` without synthetic provider receipts or observation +phases. Group-key rotation uses the revision-bound atomic `commit_group_key_rotation`, which keeps +protected application writes blocked until the exact current-epoch artifact and its operational +effect completion are durable. + +## Metrics and privacy + +Allowed metric dimensions are aggregate phase, stable error class, queue saturation, retry count, +and latency bucket. Never use account, event, checkpoint, device, controller, guardian, provider +relationship, lookup handle, or peer identifiers as metric labels. `OperationalMetricsSnapshot` +exposes only aggregate counts (`pending`, `completed`, `retry_scheduled`, `terminal_failures`, and +`publication_shortfalls`). Detailed identifiers belong only in access-controlled durable audit +records. + +Anchor backends receive only `OpaqueProviderAnchorCommitment`. They may return bounded opaque +inclusion/status evidence, but chain selection, fees, tokens, and vendor semantics are outside this +crate and cannot authorize an account. The opaque value commits to the complete verified compaction +manifest, not only its archive digest. Backend evidence must be nonempty and at most 16 KiB. A +pending, included, or rejected anchor status never changes account authority, provider admission, +compaction authorization, or recovery validity. + +## Provider verification commands + +Run these focused Rust 1.91 gates after changing any schema, commitment, store layout, or recovery +rule. They are commands to execute and record in the current-tree verification report, not a claim +that repository tests replace independent interoperability or security review. + +```console +cargo +1.91.0 test --locked -p krikos-identity --test provider_wire_formats +cargo +1.91.0 test --locked -p krikos-identity --lib audit::tests +cargo +1.91.0 test --locked -p krikos-identity --lib provider::compaction::tests +cargo +1.91.0 test --locked -p krikos-identity --features provider-store --lib \ + provider::redb::tests -- --test-threads=1 +cargo +1.91.0 test --locked -p krikos-identity --features provider-store --lib \ + audit::redb::tests -- --test-threads=1 +cargo +1.91.0 test --locked -p krikos-identity --features provider-store \ + --test provider_persistence +``` + +`provider_wire_formats` covers canonical manifest/chunk round trips, the 257-item multi-chunk +assembly path, out-of-order delivery, duplicate replay, truncation, gaps, overlaps, component swaps, +unknown versions/codepoints, count/byte tampering, and incomplete recovery finish. The provider and +audit redb unit suites cover store-v6 rejection, prepared-owner preimages, exact portable preflight +on reopen, failed-preflight non-mutation, signer resumption, audit-v1 rejection, v2 gap/extra-record +rejection, failed-CAS atomicity, and the 257-append no-prior-rescan canaries. The persistence suite +covers exact recovery/compaction commitments, continuation checkpoint evidence, immutable archive +restore, deep validated reopen of locally sealed state without reconstructing a missing full export, +sealed mutation denial, route isolation, concurrency, and terminal audit evidence. + +The complete feature, documentation, workspace, vector, fuzz, simulator, packaging, and closed +release-policy gates remain listed in [`design-evidence.md`](design-evidence.md). Stable publication +still requires all six external approvals in [`release-gate.md`](release-gate.md). diff --git a/protocols/krikos-identity/docs/release-gate.md b/protocols/krikos-identity/docs/release-gate.md new file mode 100644 index 00000000000..16a9a6d4ca6 --- /dev/null +++ b/protocols/krikos-identity/docs/release-gate.md @@ -0,0 +1,57 @@ +# Identity stable-release gate + +`krikos-identity` is a workspace member so its implementation can receive the full repository test +matrix, but it is not yet authorized for a stable registry release. The machine-readable policy is +[`../release-gate.toml`](../release-gate.toml), enforced by +[`../../../scripts/check-identity-release-gate.py`](../../../scripts/check-identity-release-gate.py). +This gate applies only to `krikos-identity`; it does not add a fifth package to the separate +four-package framework gate. + +While the policy is `blocked`, the checker requires all of the following publication boundaries: + +- the package remains a root workspace member and retains `publish = false`; +- `scripts/verify-release-packages.sh` excludes `krikos-identity` from its dependency-ordered + publication set; and +- `Makefile.toml` keeps the crate in the `cargo-check-external-types` skip list until its public API + baseline is approved. + +CI and release preflight prove that state with: + +```console +python3 scripts/check-identity-release-gate.py --expect-closed +``` + +`--require-open` fails closed and reports every outstanding approval. + +## Approval checklist + +Each approval is independent. Setting one to `true` requires at least one non-empty immutable or +repository-owned reference in the matching `evidence` array. An unavailable system, planned review, +or repository-owned self-test is not external evidence. + +| Approval | Minimum evidence before approval | Decision authority | +| --- | --- | --- | +| `third_party_security_audit` | A named independent cryptographic and protocol audit covering the stable candidate, with all critical/high findings resolved or explicitly release-blocked and the report revision retained. | Security owner and repository owner. | +| `independently_maintained_interoperability` | A separately maintained implementation validates the public v1 fixtures, negative cases, and complete pairing, proposal, recovery, provider, and sync ceremonies in both directions. | Protocol owner and repository owner. | +| `production_provider_diversity` | Production evidence demonstrates the stable profile's provider quorum across independently administered infrastructure and failure domains, including a provider-loss drill. | Production operations owner and security owner. | +| `protocol_governance` | A published process owns wire changes, codepoint allocation, compatibility windows, vulnerability handling, and protocol-upgrade decisions. | Protocol owner and repository owner. | +| `public_api_semver_baseline` | An immutable public API/rustdoc baseline, SemVer policy, compatibility result, and release ownership are reviewed for the stable crate. | Crate owner and repository owner. | +| `persistent_schema_support` | The account and provider schemas have documented support windows, forward/rollback rules, migration fixtures, backup/restore drills, and an assigned operational owner. | Storage owner and repository owner. | + +## Opening the gate + +Opening is one coordinated, owner-reviewed publication change: + +1. Record qualifying references in every `evidence` array, set all six approvals to `true`, and set + `status = "open"`. +2. Remove `publish = false` (or replace it with explicit `publish = true`), add `krikos-identity` + exactly once to the publishable package order after its dependencies, and remove + `protocols/krikos-identity` from the external-types skip list. An empty or registry-restricting + Cargo publish allowlist is not an open stable-release boundary. +3. Switch CI, release preflight, local aggregate, Makefile, and source-contract expectations from + `--expect-closed` to `--require-open`; update the release checklist and package-order contract in + the same change. +4. Run `python3 scripts/check-identity-release-gate.py --require-open` and the full stable-release + verification matrix on the immutable candidate. + +Flipping only the policy, only package metadata, or only automation leaves the checker red. diff --git a/protocols/krikos-identity/docs/security-and-deployment.md b/protocols/krikos-identity/docs/security-and-deployment.md new file mode 100644 index 00000000000..d12b1ca8bad --- /dev/null +++ b/protocols/krikos-identity/docs/security-and-deployment.md @@ -0,0 +1,404 @@ +# Krikos Identity security and deployment guide + +This document describes the implemented v1 security boundary and the operating choices that an +application or service must make around it. It is normative for deployment behavior, but it does +not declare the protocol production-ready. The foundational, account-control, synchronization, and +network-envelope profile currently documented in [`../README.md`](../README.md) is normative for +that scope. The normative provider-portability appendix and provider database procedures are in +[`provider-operations.md`](provider-operations.md). + +## Scope + +The guide covers account-control authority, device and endpoint authorization, transparency, +recovery, encrypted backups, privacy-sensitive artifacts, deployment profiles, migrations, +incident response, and release gates. It excludes civil identity, universal reputation, +application-wide ordering, mandatory public chains or tokens, retroactive erasure of plaintext +already learned by a revoked device, and anonymity against a global network observer. + +## Goals + +- Preserve a stable account identifier while every device, endpoint key, controller, and supported + controller signature suite can be replaced under explicit prior authority. +- Make offline decisions state-relative and make online freshness claims identify their exact + checkpoint and provider evidence. +- Detect conflicting account-control histories and require an authorized explicit resolution. +- Keep device labels, relationship labels, guardian membership, application data, and key material + outside public account state. +- Bound all protocol-controlled decoding, storage pages, queues, proof paths, retries, and network + sessions before they consume untrusted resources. + +## Trust and authority boundaries + +The following distinctions are mandatory: + +| Component or fact | What it proves | What it never proves | +| --- | --- | --- | +| Krikos endpoint handshake | Control of the authenticated endpoint key on this connection | Account membership or current device authorization | +| Verified account event | A transition satisfied the previous account policy and its exact admission evidence | Global freshness when the verifier is offline | +| Verified checkpoint | A signed summary matches a reconstructed account state | That no newer checkpoint exists unless freshness evidence establishes it | +| Transparency provider | Inclusion, append-only history, and signed observation time for its configured generation | Account authority or permission to create a transition | +| Social attestation or name claim | A bounded, signed hint checked against caller-supplied authority facts | Controller, device, recovery, or capability authority | +| Application capability | Permission for one structural namespace/action/resource request at an exact account basis | Account-control authority or permission outside that request | +| Encrypted backup | Confidentiality and integrity of the enclosed authority bundle and optional application data | Freshness relative to histories learned after the backup checkpoint | + +An authenticated application connection therefore requires both the transport endpoint proof and a +verified active device binding at the exact account/checkpoint basis. Pairing and recovery are +bootstrap protocols and use their ceremony-specific authority instead of pretending the proposed +or replacement device is already active. + +The pure projection in [`../src/state.rs`](../src/state.rs) owns no clock, network, randomness, or +storage. Callers supply authenticated time/freshness evidence and persist source records and +idempotent effects through [`../src/store.rs`](../src/store.rs) and +[`../src/operations.rs`](../src/operations.rs). Availability mechanisms can delay or deny work but +cannot manufacture an authorization token. + +### Network handler boundary + +The `net` feature owns the only adapter that can turn a handshake-completed +`krikos::endpoint::Connection` into `AuthenticatedTransportBinding`. It checks the exact pairing +ALPN, obtains the authenticated remote endpoint from the connection, captures the local endpoint ID +from `Connection::local_id()` as recorded by the owning endpoint during handshake completion, and +derives exporter material under a fixed v1 label. The adapter accepts no caller-supplied endpoint ID. +The exporter context contains the exact ALPN and the two endpoint IDs in byte-sorted order, so both +connection directions derive the same binding without making the roles ambiguous. Raw exporter +bytes are zeroized after they enter the crate-private adapter. + +Pairing, sync, proposal, checkpoint, transparency-gossip, and recovery each have a concrete exact- +ALPN handler. Sync, proposal, and checkpoint requests carry an account/checkpoint/device tuple and +are dispatched only after the authenticated remote endpoint is active in that exact verified +checkpoint. Pairing instead binds the proposed endpoint in the ticket to the completed handshake; +gossip and guardian recovery retain their own signature/authority checks at the service boundary. +Every request is canonical and length-delimited before decoding, every response is bounded, and +each connection owns one supervised stream task with cancellation and observable failure. + +## Threat model + +The implementation is designed to fail closed against: + +- a compromised, revoked, suspended, unknown, or endpoint-mismatched device; +- a controller that is absent from the authoritative pre-state, outside the operation scope, + retired, duplicated, below threshold, or using an invalid signature; +- reordered, replayed, concurrent, stale-predecessor, or conflicting account events; +- provider rollback, same-size equivocation, forged inclusion/consistency proofs, an unconfigured + provider, or evidence from the wrong log/key generation; +- stale, future-dated, unrelated, or threshold-insufficient freshness and recovery observations; +- malformed, non-canonical, oversized, non-minimal, unsupported, or critically extended wire data; +- pairing transcript substitution, endpoint-role key reuse, expired/replayed tickets, one-sided + confirmation, and transport-exporter substitution; +- capability broadening, delegation cycles, expired or revoked grants, and wrong checkpoint/epoch + contexts; +- backup corruption, wrong passphrases, altered authenticated context, and authority bundles that + do not replay to their signed checkpoint. + +The model does not defeat a device or controller while it is still legitimately authorized, stop a +malicious provider from refusing service, make revocation instantly visible to a disconnected +verifier, recover plaintext already disclosed before revocation, or hide traffic patterns from a +global observer. Multi-provider policy reduces but does not remove correlated-provider risk. + +## Privacy model + +Public account state contains self-certifying identifiers, public keys, policy and algorithm +versions, epochs, hashes, blinded commitments, provider descriptors, proofs, and signed +checkpoints. Network peers additionally learn the endpoint keys, ALPN, timing, and address/relay +metadata needed for their connection. + +Private or encrypted local storage must retain device labels, detailed revocation reasons, social +relationships, guardian identities and weights, recovery openings, application membership/data, +backup passphrases, blinding secrets, lookup secrets, pairwise-master secrets, agreement secrets, +and group keys. The secret-bearing wrappers in [`../src/privacy.rs`](../src/privacy.rs), +[`../src/recovery.rs`](../src/recovery.rs), and [`../src/key_wrap.rs`](../src/key_wrap.rs) are +redacted, non-`Copy`, and where appropriate non-`Clone`; raw private guardian grants/openings do not +implement the public canonical wire interface. + +Blinded or pairwise identifiers limit direct disclosure but do not make low-entropy values safe by +themselves. Producers must use fresh high-entropy blindings. Rotating lookup handles and pairwise +identifiers reduce cross-context correlation; they do not conceal transport metadata or a +relying-party's own observations. + +Operational metrics may use aggregate phases, stable error classes, queue saturation, retry counts, +and latency buckets. Account, event, checkpoint, device, controller, guardian, relationship, +lookup-handle, and peer identifiers must not be metric labels. Detailed identifiers belong only in +access-controlled audit records. + +## Security-critical invariants + +Every integration must preserve all twelve identity invariants: + +1. An account is not an endpoint or device. +2. No ordinary account private key is copied to every device. +3. Every device is independently identifiable, authorizable, and revocable. +4. Every account-control transition is authorized under the exact previous policy and advances + from its declared predecessor set. +5. Account identity survives complete authorized device and controller rotation. +6. Providers distribute and timestamp authorized material but cannot create account authority. +7. Social relationships grant no account authority unless an explicit, independently verified + policy consumes them. +8. Revocation is externally discoverable only after its proof is durably published; local + authorization alone is not reported as observed publication. +9. Offline validation states its known checkpoint and epoch basis and is never presented as + globally current. +10. Sensitive decisions fail closed when required freshness or account consistency is unavailable. +11. A removed device receives no future application group keys, and protected writes remain + blocked until the current epoch's required group-key rotation is durably committed. +12. Conflicting security histories are retained as a fork and explicitly resolved, never silently + merged or selected by arrival time. + +Recovery must additionally install its complete declared replacement authority without silently +retaining an omitted old controller or active device. Network frames, history pages, sessions, +queues, proof paths, and retry loops enforce both item and byte/work bounds. + +## Deployment profiles + +These profiles are application choices layered on the same v1 schemas. They are not constructors +that silently weaken an account's committed policy. + +### Local-only + +- Use a `ProviderPolicy::local_only` account policy and the memory or `fs-store` account store. +- Replicate source events and checkpoints directly among authorized devices. +- Treat revocation as opportunistic: a disconnected verifier cannot learn a newer event. +- Complete operational checkpoint work at the policy-bound authorized stage without inventing + provider receipts or observation. +- Suitable for isolated networks or lower-risk data where provider availability is deliberately + traded for reduced public metadata. + +### Consumer + +- Commit at least three independently operated providers and require at least two receipts for the + account's sufficient publication threshold. +- Use QR pairing with two-party SAS confirmation and durable pairing-nonce tombstones. +- Keep a daily controller plus a separately stored offline recovery controller. +- Require recent provider evidence for sensitive changes and rotate protected application keys + after every authority-affecting revocation or recovery. + +### High-security + +- Use a weighted 2-of-3 or 3-of-5 controller policy with hardware/offline controller classes. +- Require several independently administered provider generations, threshold publication, later + observation, and independent auditor comparison before treating sensitive changes as current. +- Use delayed recovery with explicit notifications, short-lived sensitive-operation credentials, + encrypted offline backups, and tested recovery drills. +- An optional external anchor may commit the opaque hash of one complete verified provider + compaction manifest. The anchor remains non-authoritative and chain/vendor semantics stay outside + this crate. + +### Enterprise + +- Represent organization roles as scoped weighted controllers; keep department/application + authority in structural capabilities rather than shared account secrets. +- Place controller keys in deployment-specific HSM boundaries and normalize their exact signing + request display outside this portable crate. +- Operate internal and external providers, retain auditable policy templates and full recovery + exports, and define incident-retention requirements before irreversible local sealing releases + superseded provider material. +- Integrate aggregate metrics, access-controlled audit export, backup custody, recovery drills, and + protocol/crypto migration into the organization's change-management process. + +## Recovery and backup operations + +Only one authoritative recovery may be pending for an account. A recovery proposal commits its +prior event head/checkpoint, nonce, replacement keys and policy, retained devices/controllers, and +the authority that must approve it. Begin admission derives its delay anchor from authenticated +provider observations; finalize cannot substitute a different admission anchor or guardian set. +Veto uses the pre-recovery control policy. Cancel uses the same pre-recovery recovery authority and, +for guardian recovery, exact provider observation of the cancel intent. Finalize installs the +declared authority atomically, revokes omitted active devices/controllers, and emits group-key +rotation, checkpoint, publication, and notification effects. + +Store and operational-journal recovery is retry-based: reopen the account and effect stores, +reconstruct from canonical source records, reclaim an expired bounded lease, and rerun the same +stable effect. Never skip directly to a later phase or relabel a partial publication as observed. +Protected application writes remain blocked until the exact current-revision group-key rotation is +durable. + +`BackupEnvelope` encrypts a fully replayable `BackupAuthorityBundle` and optional application bytes +with the fixed serialized Argon2id v1 profile, a fresh salt, independently fresh XChaCha20-Poly1305 +wrapping/content nonces, and a fresh random content key. Restoration authenticates and decrypts the +envelope, replays every account event, and verifies the signed checkpoint. Account authority and +optional application-data restoration are reported separately. Wrong passphrases and ciphertext +corruption intentionally share one authentication-failure class. + +Backups are checkpoint-relative, not globally current. After restoration, compare against the +configured providers and known peers before sensitive use. Keep at least one offline copy of the +authority material and passphrase under separate custody; the crate does not implement human +custody, cloud synchronization, secret sharing, or automatic rollback selection. + +## Migration rules + +### Controller cryptography + +Cryptographic migration is an account-control state machine, not a configuration toggle. Begin +commits the candidate suite and cross-certified controller bindings; activation enters the dual +suite phase; retirement removes the old suite or aborts an unactivated candidate. During overlap, +account events and checkpoints require the exact active suite set. Retired suites, keys, and epochs +are tombstones and cannot be reused. A future digest break requires the successor-account path; +v1's original `AccountId` remains derived from its original genesis digest. + +### Protocol version + +Every independently signed or authoritative top-level wire structure names a version; nested +primitives inherit that enclosing version and do not invent a second version field. Every network +protocol names a major version in its ALPN. Unknown major versions and unknown critical extensions +fail closed. An authorized `UpgradeProtocol` transition is required before accepting a new account +protocol. Run old and new network handlers only for an explicitly documented compatibility window; +never reinterpret v1 bytes under a new schema. + +### Provider generation + +A provider signing-key change creates a new self-certifying provider descriptor, provider ID, log +ID, genesis key version, database path, and account-authorized provider policy. In-place generation +mutation is rejected. Retain and audit the old generation; select service data only from the exact +current account-authorized generation. See [`provider-operations.md`](provider-operations.md) for +export, compaction-manifest, mirror, and corruption procedures. + +The current redb provider-generation layout is store version 7 and the normalized redb audit +journal is version 2. They are persistent implementation schemas, not portable interchange. Both +reject legacy versions explicitly and perform no automatic migration. Treat an upgrade as a +backup/restore boundary: preserve the old bytes, produce and verify a complete recovery export with +the old compatible binary, and restore into a new path only through an explicitly reviewed +migration procedure. Provider v7 reruns exact portable-size preflight for a prepared candidate on +reopen; audit v2 performs full contiguous replay on open, then uses a constant-size cursor and +single-record atomic CAS for observations. Active and complete-archive provider stores reconstruct +exact portable accounting on reopen. A locally sealed store instead uses a never-consulted +read-only sentinel because its complete export was deliberately released; every mutation rejects +the sealed state before consulting that sentinel. Never edit either database to bypass those +checks. + +## Incident response + +### Lost or compromised device + +1. Obtain the freshest consistent account basis available under local policy. +2. Authorize `SuspendDevice` when compromise is uncertain, or `RevokeDevice`/`RotateDeviceKeys` + when removal is required. +3. Commit the event and its mandatory outbox effects atomically. +4. Rotate every affected application group key and keep protected writes blocked until the exact + current-epoch rotations commit. +5. Build/sign a checkpoint, publish to the committed provider threshold, journal receipts and later + observation, notify peers, and preserve audit evidence. + +### Controller compromise + +Use the uncompromised pre-state threshold to remove or rotate the controller and change policy if +needed. If that threshold is unavailable, use the committed recovery policy; do not edit controller +storage out of band. Treat histories authorized by conflicting controller sets as forks and resolve +them only with `ResolveFork` under the common pre-fork policy. + +### Provider rollback or equivocation + +Stop accepting the suspect generation for freshness. Preserve both signed heads, consistency +proofs, receipts, database/export bytes, and the durable auditor record. Compare with independent +peers/providers. Change provider policy through an authorized account event and start a fresh +generation; never let a longer untrusted log override account authority. + +### Store corruption or interrupted operation + +Stop writes and preserve the original bytes. Reopen through the validating adapter; corruption must +not become an empty account or log. Restore from a fully verified export/backup into a new path, +compare signed heads/checkpoints, and replay the idempotent operational journal. Do not delete the +old path until incident retention and independent verification are complete. + +### Suspected algorithm failure + +Freeze sensitive operations if the current policy cannot establish a trustworthy basis. Use the +authorized crypto-migration state machine when the original signature/digest assumptions still +permit authorization. A break that invalidates the original account digest requires a successor +account and application-specific re-binding; v1 does not claim transparent identity continuity in +that case. + +## Verification and audit checklist + +Before a deployment is promoted, record evidence for: + +- `scripts/check-identity-feature-matrix.sh`, including its + `scripts/tests/check-identity-os-rng-boundary.sh` source/manifest check and no-default dependency + isolation, plus Rust 1.91 all-target tests and strict Clippy; +- canonical-vector validation, decoder/verifier inventory, and bounded parser/state-machine tests; +- deterministic simulation replay and the repository-owned bounded formal-model command; +- memory and redb reopen/fault matrices for events, checkpoints, recovery, rotations, provider + generations, receipts, notifications, retries, exports, compaction manifests, and auditors; +- the focused provider portability and persistence commands in + [`provider-operations.md`](provider-operations.md), including manifest/chunk tampering, store-v6 + and audit-v1 rejection, prepared-candidate preflight/reopen, atomic CAS failure, and recovery + archive immutability; +- real two-node direct and local-relay tests for the six v1 ALPN handlers, including endpoint, + checkpoint, ALPN, framing, backpressure, cancellation, and shutdown failures; +- aggregate-only metrics, access control for detailed audit records, backup restoration drills, and + provider diversity appropriate to the selected profile; +- dependency isolation, public API/rustdoc coverage, resource-bound review, `unsafe`/panic review, + formatting, architecture, packaging, release, and compatibility checks. +- the stable-release policy remains fail-closed under + `python3 scripts/check-identity-release-gate.py --expect-closed` until every approval in + [`release-gate.md`](release-gate.md) has qualifying evidence. + +The authoritative repository acceptance matrix is +[`design-evidence.md`](design-evidence.md); a skipped command or unavailable external system is a +residual gate, not a pass. + +## Frozen v1 product decisions + +The stable wire profile requires explicit choices for each of the following questions. Version 1 +resolves them as follows; changing a wire-significant answer requires an authorized +protocol upgrade rather than reinterpretation of existing bytes. + +| Design question | Version 1 decision | +| --- | --- | +| Canonical serialization | Postcard 1.1.3 with the exact bounded, re-encode-checked profile and closed codepoints in the crate README and provider appendix. JSON is metadata only. | +| Provider lookup | Public account history may be keyed by `AccountId`; privacy-sensitive discovery uses a provider/account/generation-bound rotating `PrivateCheckpointLookupHandle`. Deployments choose which interface they expose. | +| Device non-membership | A sorted Merkle set with domain-separated typed leaves and adjacent-neighbor non-membership proofs. | +| Consumer provider threshold | The recommended profile configures three distinct providers, two as the sufficient threshold, and three as preferred replication. These are explicit policy fields, not hidden global defaults. | +| Epoch increments | The closed 22-operation table and mode-specific rules in `state.rs` are authoritative. Metadata-only update, migration begin, and migration abort are the deliberately non-incrementing cases. | +| Emergency controller loss | There is no out-of-band one-controller bypass. Use the applicable previous control threshold or the account's already committed recovery policy; conflicts remain forks. | +| Proposal serialization | V1 uses exact revision compare-and-swap, immutable admission evidence, and explicit fork retention rather than an authority-bearing lease. A local bounded lease may coordinate work but cannot suppress another valid branch. | +| Clock dependence | Pairing and presence accept at most two minutes of future skew; presence lasts at most five minutes and pairing at most ten. Account recovery delay/freshness uses signed provider observation time and explicit verifier time, never event metadata or an ambient clock. | +| Secure hardware | The portable boundary supplies exact typed bytes, key, purpose, account, epoch, and operation display facts. Platform attestation, UX, backup, and hardware normalization remain an external deployment gate. | +| Post-quantum migration | Algorithm-tagged bounded fields, cross-certified controller bindings, dual-suite overlap, retirement tombstones, and successor-account support are reserved. V1 does not select or claim support for a post-quantum suite. A digest break requires a successor account. | +| Pairwise identifiers and names | Pairwise IDs are relying-party scoped; public names are optional signed aliases. Neither replaces the stable `AccountId` or grants authority. | +| Data recovery boundary | The core backup can carry a replayable account-authority bundle and optional opaque application data, reports their restoration separately, and does not promise application-specific conflict resolution or cloud custody. | +| Encrypted provider indices | V1 supplies rotating private lookup handles and opaque anchor commitments but does not standardize an encrypted account-index record. Such a record requires a future protocol extension and leakage analysis. | +| Open standard boundary | The README's documented foundational, account-control, synchronization, and network-envelope profile, together with the provider procedures, checked fixtures, models, and protocol tests, are repository-owned v1 specification assets for their stated scopes. A separately maintained implementation and standards governance remain external release gates. | + +## Evidence + +Confirmed implementation evidence: + +- account projection, fork and recovery invariants: [`../src/state.rs`](../src/state.rs), + [`../tests/state_machine.rs`](../tests/state_machine.rs), and + [`../tests/recovery_guardians.rs`](../tests/recovery_guardians.rs); +- atomic source/effect persistence and crash recovery: [`../src/store.rs`](../src/store.rs), + [`../src/operations.rs`](../src/operations.rs), + [`../tests/store_conformance.rs`](../tests/store_conformance.rs), and + [`../tests/operational_recovery.rs`](../tests/operational_recovery.rs); +- provider proofs, publication, auditing, persistence, compaction, and anchoring: + [`../src/provider.rs`](../src/provider.rs), + [`../src/provider/interchange.rs`](../src/provider/interchange.rs), + [`../src/provider/compaction.rs`](../src/provider/compaction.rs), + [`../src/provider/redb.rs`](../src/provider/redb.rs), [`../src/audit.rs`](../src/audit.rs), + [`../src/audit/redb.rs`](../src/audit/redb.rs), [`../src/publication.rs`](../src/publication.rs), + [`../tests/provider_wire_formats.rs`](../tests/provider_wire_formats.rs), and + [`../tests/provider_persistence.rs`](../tests/provider_persistence.rs); +- private artifacts, recovery openings, and backups: [`../src/privacy.rs`](../src/privacy.rs), + [`../src/recovery.rs`](../src/recovery.rs), + [`../tests/privacy_boundaries.rs`](../tests/privacy_boundaries.rs), and + [`../tests/private_backup.rs`](../tests/private_backup.rs); +- resource bounds and canonical decoding: [`../src/limits.rs`](../src/limits.rs), + [`../src/codec.rs`](../src/codec.rs), and + [`../tests/schema_limits.rs`](../tests/schema_limits.rs). + +## Open questions and external release gates + +The stable publication criteria and decision authorities are normative in +[`release-gate.md`](release-gate.md) and machine-readable in +[`../release-gate.toml`](../release-gate.toml). These additional deployment questions are +deliberately not represented as completed repository work: + +1. A third-party cryptographic and protocol security audit. +2. An independent implementation validating every interoperability vector and ceremony. +3. Production evidence that configured providers have genuinely independent operators, + infrastructure, and failure domains. +4. Platform-specific secure-hardware/HSM request-display and attestation normalization. +5. Application policy for restored-but-not-yet-refreshed backups and later-discovered stale + application events. +6. A chain/vendor choice, if an operator elects to anchor opaque provider commitments externally. +7. Operational validation of relay/provider capacity and abuse controls at the deployment's target + scale. diff --git a/protocols/krikos-identity/examples/generate_interop_vectors.rs b/protocols/krikos-identity/examples/generate_interop_vectors.rs new file mode 100644 index 00000000000..cebc82e71f1 --- /dev/null +++ b/protocols/krikos-identity/examples/generate_interop_vectors.rs @@ -0,0 +1,3778 @@ +//! Regenerate the checked-in identity-protocol interoperability assets. +//! +//! This tool is intentionally separate from the validator. Run it explicitly with +//! `cargo run -p krikos-identity --example generate_interop_vectors` and review every changed +//! binary and metadata entry. Normal tests never write fixture files. + +use std::{ + collections::BTreeMap, + env, fs, + path::{Path, PathBuf}, +}; + +use krikos_base::SecretKey; +use krikos_identity::{ + merkle::{ + MerkleConsistencyProof, MerkleInclusionProof, MerkleNonMembershipProof, MerkleSetLeaf, + }, + net::{ + AuthorizedCheckpointRequest, AuthorizedProposalRequest, AuthorizedSyncRequest, + EndpointAuthorizationRequest, IdentityProtocolAck, IdentityProtocolKind, + IdentityProtocolReply, IdentityServiceOutcome, + }, + *, +}; +use serde::{Deserialize, Serialize}; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; + +struct AllowProviderAdmission; + +impl ProviderAdmissionControl for AllowProviderAdmission { + fn check( + &self, + _admission: ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +struct InteropProviderSigner<'a>(&'a SecretKey); + +impl ProviderHeadSigner for InteropProviderSigner<'_> { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +#[allow(dead_code, unused_imports)] +mod task2 { + include!("../tests/task2_golden_vectors.rs"); + + pub(super) fn operations() -> Vec { + account_operations() + } + + pub(super) fn recovery_values() -> ( + RecoveryProposal, + BeginRecovery, + VetoRecovery, + CancelRecovery, + FinalizeRecovery, + GuardianApprovalBody, + SignedGuardianApproval, + GuardianApprovalSet, + RecoveryThresholdEvidence, + ) { + let proposal = recovery_proposal(); + let (begin, veto, cancel, finalize) = recovery_operations(); + let (body, signed, approvals, evidence) = guardian_evidence(); + ( + proposal, + proposal_to_begin(begin), + veto, + cancel, + finalize, + body, + signed, + approvals, + evidence, + ) + } + + fn proposal_to_begin(begin: BeginRecovery) -> BeginRecovery { + begin + } + + pub(super) fn migration_values() -> (BeginCryptoMigration, CryptoMigrationId) { + migration_parts() + } + + pub(super) fn recovery_plan_anchor_and_fork() + -> (RecoveryAuthorityPlan, RecoveryDelayAnchor, ForkDescriptor) { + let proposal = recovery_proposal(); + let (_, _, _, finalize) = recovery_operations(); + let fork = account_operations() + .into_iter() + .find_map(|operation| match operation { + AccountOperation::ResolveFork(resolution) => Some(resolution.fork().clone()), + _ => None, + }) + .unwrap(); + ( + proposal.plan().clone(), + finalize.delay_anchor().clone(), + fork, + ) + } + + fn transition_event(operation: AccountOperation, fill: u8) -> AuthorizedEvent { + let requires_empty_approvals = matches!(operation, AccountOperation::FinalizeRecovery(_)); + let body = EventBody::new( + typed_id::(1), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id::(fill)), + operation, + Timestamp::from_unix_millis(u64::from(fill)), + [fill; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(fill.wrapping_add(1)); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + typed_id::(fill.wrapping_add(2)), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + let approvals = if requires_empty_approvals { + Vec::new() + } else { + vec![ + SignedControllerApproval::new( + ControllerApprovalBody::event( + typed_id::(fill.wrapping_add(3)), + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(), + vec![keyed_signature(fill.wrapping_add(4))], + ) + .unwrap(), + ] + }; + AuthorizedEvent::new(body, evidence, ControllerApprovals::new(approvals).unwrap()).unwrap() + } + + fn transition_checkpoint( + event: &AuthorizedEvent, + lifecycle: AccountLifecycle, + ) -> SignedCheckpoint { + let body = CheckpointBody::new( + event.body().account_id(), + Epoch::new(2), + Sequence::new(2), + event.event_id().unwrap(), + digest(0xa1), + digest(0xa2), + digest(0xa3), + typed_id::(0xa4), + typed_id::(0xa5), + typed_id::(0xa6), + typed_id::(0xa7), + lifecycle, + Timestamp::from_unix_millis(900), + Extensions::default(), + ) + .unwrap(); + SignedCheckpoint::new( + body, + CheckpointAuthorization::transition_derived(event).unwrap(), + ) + .unwrap() + } + + pub(super) fn transition_checkpoints() -> (SignedCheckpoint, SignedCheckpoint) { + let (_, _, _, finalize) = recovery_operations(); + let finalize_event = transition_event(AccountOperation::FinalizeRecovery(finalize), 0xb0); + let retire_event = transition_authorized_event(); + ( + transition_checkpoint(&finalize_event, AccountLifecycle::Active), + transition_checkpoint(&retire_event, AccountLifecycle::Retired), + ) + } +} + +#[allow(dead_code, unused_imports)] +mod backup { + include!("../tests/private_backup.rs"); + + pub(super) struct Fixture { + pub signer: SecretKey, + pub genesis: AccountGenesis, + pub event: krikos_identity::AuthorizedEvent, + pub checkpoint: SignedCheckpoint, + pub bundle: BackupAuthorityBundle, + pub envelope: BackupEnvelope, + } + + pub(super) fn fixture() -> Fixture { + let signer = SecretKey::from_bytes(&[0x11; 32]); + let genesis = genesis(&signer); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let event = authorized_event(&state, &signer); + state.validate_and_apply(&event).unwrap(); + let checkpoint = signed_checkpoint( + &state, + &signer, + build_checkpoint_body(&state, Timestamp::from_unix_millis(99)).unwrap(), + ); + let bundle = BackupAuthorityBundle::try_new( + genesis.clone(), + vec![event.clone()], + checkpoint.clone(), + ) + .unwrap(); + let passphrase = + BackupPassphrase::try_new(b"correct horse battery staple".to_vec()).unwrap(); + let envelope = BackupEnvelope::seal_with_rng( + context(&bundle), + &passphrase, + &bundle, + None, + &mut RepeatingRng(0x64), + ) + .unwrap(); + Fixture { + signer, + genesis, + event, + checkpoint, + bundle, + envelope, + } + } + + pub(super) fn migration_checkpoints() -> (SignedCheckpoint, SignedCheckpoint) { + let signer = SecretKey::from_bytes(&[0x11; 32]); + let genesis = genesis(&signer); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let event = authorized_event(&state, &signer); + state.validate_and_apply(&event).unwrap(); + let make = |lifecycle| { + let body = CheckpointBody::new( + state.account_id(), + state.epoch(), + state.sequence(), + event.event_id().unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0xc1; 32]), + Digest::new(HashAlgorithm::Blake3_256, [0xc2; 32]), + Digest::new(HashAlgorithm::Blake3_256, [0xc3; 32]), + state.control_policy_id(), + state.recovery_policy_id(), + state.provider_policy_id(), + typed_id::(0xc4), + lifecycle, + Timestamp::from_unix_millis(101), + Extensions::default(), + ) + .unwrap(); + signed_checkpoint(&state, &signer, body) + }; + ( + make(krikos_identity::AccountLifecycle::MigrationPending), + make(krikos_identity::AccountLifecycle::MigrationDual), + ) + } +} + +#[allow(dead_code, unused_imports)] +mod application_fixture { + include!("../tests/application_verification.rs"); + + pub(super) fn fixture() -> (SecretKey, DeviceAuthorization, SignedApplicationEvent) { + let secret = SecretKey::from_bytes(&[0x31; 32]); + let authorization = fixture_authorization(&secret); + let event = signed_event(&secret, &authorization, context(), b"payload".to_vec()); + (secret, authorization, event) + } +} + +#[allow(dead_code, unused_imports)] +mod capability_fixture { + include!("../tests/capability_schema.rs"); + + pub(super) struct Fixture { + pub secret: krikos_base::SecretKey, + pub grant: CapabilityGrant, + pub root: CapabilityRoot, + pub body: DelegationBody, + pub signed: SignedDelegation, + pub chain: DelegationChain, + } + + pub(super) fn fixture() -> Fixture { + let secret = krikos_base::SecretKey::from_bytes(&[0x35; 32]); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(1))], + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()), + Some(Timestamp::from_unix_millis(300)), + ); + let root = CapabilityRoot::new( + context(1, 1), + device_id(10), + root_grant.clone(), + Extensions::default(), + ) + .unwrap(); + let grant = grant( + ResourceSelector::prefix(path(&[b"collection", b"blue"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(2))], + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + Some(Timestamp::from_unix_millis(250)), + ); + let body = DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + grant.clone(), + device_id(10), + device_id(11), + context(1, 2), + Timestamp::from_unix_millis(20), + [1; 16], + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&body.to_canonical_bytes().unwrap()); + let signed = SignedDelegation::new( + body.clone(), + ProtocolSignature::ed25519(signature.to_bytes()), + ); + let chain = DelegationChain::new(root.clone(), vec![signed.clone()]).unwrap(); + Fixture { + secret, + grant, + root, + body, + signed, + chain, + } + } +} + +#[allow(dead_code, unused_imports)] +mod key_wrap_fixture { + include!("../tests/key_rotation.rs"); + + pub(super) fn fixture() -> ( + GroupKeyWrapHeader, + WrappedGroupKey, + krikos_identity::RecipientKeyWraps, + ) { + let recipient_secret = AgreementSecretKey::from_bytes([0x20; 32]); + let recipient = authorization(&recipient_secret, 0, 1); + let (state, _) = active_state(std::slice::from_ref(&recipient)); + let snapshot = snapshot(&state, vec![recipient.device_id()]); + let mut random = ScriptedRng::new((0x40_u8..=0x77).collect()); + let rotation = + rotate_group_key_with_rng(&snapshot, &GroupKey::new([0x90; 32]), &mut random).unwrap(); + let wraps = rotation.recipient_key_wraps().clone(); + let wrapped = wraps.as_slice()[0].clone(); + (wrapped.header().clone(), wrapped, wraps) + } +} + +#[allow(dead_code, unused_imports)] +mod private_metadata_fixture { + include!("../tests/private_artifacts.rs"); + + pub(super) fn fixture() -> (PrivateArtifactContext, PrivateMetadataEnvelope) { + let context = context(); + let key = PrivateMetadataKey::try_new([0x31; 32]).unwrap(); + let plaintext = + PrivateMetadata::try_new(b"private profile: alpine orchid".to_vec()).unwrap(); + let envelope = PrivateMetadataEnvelope::seal_with_rng( + context.clone(), + &key, + &plaintext, + &mut RepeatingRng(0x41), + ) + .unwrap(); + (context, envelope) + } +} + +#[allow(dead_code, unused_imports)] +mod portable_fixture { + include!("../tests/privacy_boundaries.rs"); + + pub(super) fn fixture() -> (SecretKey, SignedPortableCredential) { + let secret = SecretKey::from_bytes(&[0x41; 32]); + let issuer_key = SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(); + let subject_key = + SigningPublicKey::ed25519(*SecretKey::from_bytes(&[0x42; 32]).public().as_bytes()) + .unwrap(); + let account = typed_id::(0x43); + let body = PortableCredentialBody::try_new( + account, + typed_id::(0x44), + Epoch::GENESIS, + vec![subject_key], + account, + issuer_key, + Timestamp::from_unix_millis(10), + Timestamp::from_unix_millis(20), + vec![CredentialClaim::try_new("display-name", b"Ada".to_vec()).unwrap()], + Extensions::default(), + ) + .unwrap(); + let signature = AlgorithmSignature::new( + 1, + secret + .sign(&body.signing_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(); + ( + secret, + SignedPortableCredential::try_new(body, signature).unwrap(), + ) + } +} + +#[allow(dead_code, unused_imports)] +mod social_fixture { + include!("../tests/social.rs"); + + pub(super) fn fixture() -> (SecretKey, SignedSocialAttestation) { + let issuer = SecretKey::from_bytes(&[0x11; 32]); + let subject = SecretKey::from_bytes(&[0x12; 32]); + let value = signed_attestation( + &issuer, + typed_id::(0x13), + typed_id::(0x14), + &subject, + typed_id::(0x15), + typed_id::(0x16), + 0x17, + ); + (issuer, value) + } +} + +#[allow(dead_code, unused_imports)] +mod name_fixture { + include!("../tests/names.rs"); + + pub(super) fn fixture() -> (SecretKey, SignedNameClaim) { + let secret = SecretKey::from_bytes(&[0x41; 32]); + let value = signed_claim( + "alice.example", + &secret, + typed_id::(0x42), + typed_id::(0x43), + 10, + Some(20), + ); + (secret, value) + } +} + +#[allow(dead_code, unused_imports)] +mod guardian_fixture { + include!("../tests/recovery_guardians.rs"); + + pub(super) fn fixture() -> ( + SecretKey, + GuardianApprovalBody, + SignedGuardianApproval, + GuardianApprovalSet, + krikos_identity::RecoveryThresholdEvidence, + ) { + let universe = GuardianUniverse::new(2); + let context = universe.context(GuardianApprovalDecision::Begin); + let signed = universe.approval(0, 0, context, APPROVED_AT); + let approvals = universe.approvals(&[0, 1]); + let body = signed.body().clone(); + let evidence = krikos_identity::RecoveryThresholdEvidence::guardian_approvals( + universe.policy.id().unwrap(), + POLICY_VERSION, + approvals.clone(), + ) + .unwrap(); + ( + SecretKey::from_bytes(&[1; 32]), + body, + signed, + approvals, + evidence, + ) + } +} + +#[allow(dead_code, unused_imports)] +mod transparency_fixture { + include!("../tests/transparency_crypto.rs"); + + pub(super) struct Fixture { + pub secret: SecretKey, + pub provider: ProviderDescriptor, + pub entry: ProviderLogEntryBody, + pub head: SignedProviderHead, + pub receipt: InclusionReceipt, + pub receipts: krikos_identity::ProviderReceipts, + pub evidence: ProviderEquivocationEvidence, + } + + pub(super) fn fixture() -> Fixture { + let secret = SecretKey::from_bytes(&[0x71; 32]); + let provider = provider_descriptor(&secret); + let entry = entry(&provider, 100); + let root = entry.merkle_leaf_hash().unwrap(); + let head = signed_head(&secret, &provider, root, 1, 105); + let receipt = InclusionReceipt::new(entry.clone(), 0, Vec::new(), head.clone()).unwrap(); + let receipts = krikos_identity::ProviderReceipts::new(vec![receipt.clone()]).unwrap(); + let conflicting = signed_head( + &secret, + &provider, + Digest::new(HashAlgorithm::Blake3_256, [0x99; 32]), + 1, + 106, + ); + let evidence = + ProviderEquivocationEvidence::new(&provider, head.clone(), conflicting).unwrap(); + Fixture { + secret, + provider, + entry, + head, + receipt, + receipts, + evidence, + } + } +} + +#[derive(Serialize)] +struct Manifest { + format: &'static str, + format_version: u16, + binding_schema_version: u16, + derivation_schema_version: u16, + canonical_profile: &'static str, + algorithms: BTreeMap<&'static str, &'static str>, + deterministic_keys: Vec, + private_wire_exclusions: Vec, + transient_wire_dispositions: Vec, + required_inventory: Vec, + vectors: Vec, +} + +#[derive(Serialize)] +struct KeyMetadata { + name: &'static str, + algorithm: &'static str, + test_only_secret_seed_hex: String, + public_key_hex: String, +} + +#[derive(Serialize)] +struct Exclusion { + wire_type: &'static str, + reason: &'static str, + covered_by: &'static str, +} + +#[derive(Serialize)] +struct VectorMetadata { + name: String, + wire_type: &'static str, + canonical_file: String, + canonical_hex: String, + canonical_blake3_hex: String, + encoded_length: usize, + protocol_version: Option, + version_scope: &'static str, + algorithms: Vec<&'static str>, + expected_ids: BTreeMap<&'static str, String>, + signature_bindings: Vec, + mac_bindings: Vec, + derivations: Vec, + dependencies: Vec, + tamper_cases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SignatureBinding { + name: String, + algorithm: &'static str, + domain_ascii: &'static str, + message_hex: String, + signer_key: &'static str, + public_key_hex: String, + signature_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct MacBinding { + name: String, + algorithm: &'static str, + key_derivation_algorithm: &'static str, + key_derivation_context_ascii: &'static str, + key_derivation_input_hex: String, + message_domain_ascii: &'static str, + message_hex: String, + expected_mac_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct DerivationMetadata { + output_name: &'static str, + algorithm: &'static str, + domain_or_context_ascii: &'static str, + message_hex: String, + expected_output_hex: String, +} + +#[derive(Serialize)] +struct TamperMetadata { + name: &'static str, + offset: usize, + replacement_hex: String, + expectation: &'static str, +} + +struct Catalog { + directory: PathBuf, + vectors: Vec, +} + +struct VectorDetails { + protocol_version: Option, + version_scope: &'static str, + algorithms: Vec<&'static str>, + signature_bindings: Vec, + mac_bindings: Vec, + derivations: Vec, + expected_ids: BTreeMap<&'static str, String>, + dependencies: Vec, + tamper_expectation: &'static str, +} + +impl Default for VectorDetails { + fn default() -> Self { + Self { + protocol_version: Some(1), + version_scope: "authoritative-top-level-v1", + algorithms: vec!["BLAKE3-256"], + signature_bindings: Vec::new(), + mac_bindings: Vec::new(), + derivations: Vec::new(), + expected_ids: BTreeMap::new(), + dependencies: Vec::new(), + tamper_expectation: "canonical_digest_mismatch", + } + } +} + +impl Catalog { + fn new(directory: PathBuf) -> Self { + Self { + directory, + vectors: Vec::new(), + } + } + + fn add( + &mut self, + name: impl Into, + wire_type: &'static str, + value: &T, + details: VectorDetails, + ) { + let name = name.into(); + let bytes = value.to_canonical_bytes().unwrap(); + let filename = format!("{name}.bin"); + fs::write(self.directory.join(&filename), &bytes).unwrap(); + let tamper_offset = match details.tamper_expectation { + "signature_invalid_or_decode_rejected" => { + let signature = details + .signature_bindings + .first() + .map(|binding| hex::decode(&binding.signature_hex).unwrap()) + .expect("signed vector signature"); + bytes + .windows(signature.len()) + .position(|window| window == signature.as_slice()) + .and_then(|offset| offset.checked_add(signature.len().saturating_sub(1))) + .expect("signature bytes must occur in the signed canonical envelope") + } + "authentication_or_decode_rejected" + | "private_metadata_authentication_rejected" + | "key_wrap_authentication_rejected" + | "merkle_proof_rejected" + | "cursor_authentication_rejected" => bytes.len().saturating_sub(2), + "identifier_or_binding_rejected" if wire_type == "PairingConfirmationContext" => 33, + "identifier_or_binding_rejected" => bytes.len() / 2, + _ => 0, + }; + let replacement = if bytes.get(tamper_offset).copied().unwrap_or(0) == 0 { + 0xff + } else { + 0 + }; + let mut derivations = derivations_for_wire_type(wire_type, &bytes); + derivations.extend(details.derivations); + let mut expected_ids = details.expected_ids; + for derivation in &derivations { + expected_ids + .entry(derivation.output_name) + .or_insert_with(|| format!("b3:{}", derivation.expected_output_hex)); + } + self.vectors.push(VectorMetadata { + name, + wire_type, + canonical_file: filename, + canonical_hex: hex::encode(&bytes), + canonical_blake3_hex: blake3::hash(&bytes).to_hex().to_string(), + encoded_length: bytes.len(), + protocol_version: details.protocol_version, + version_scope: details.version_scope, + algorithms: details.algorithms, + expected_ids, + signature_bindings: details.signature_bindings, + mac_bindings: details.mac_bindings, + derivations, + dependencies: details.dependencies, + tamper_cases: vec![TamperMetadata { + name: "replace-bound-byte", + offset: tamper_offset, + replacement_hex: hex::encode([replacement]), + expectation: details.tamper_expectation, + }], + }); + } +} + +fn digest_hex(digest: &Digest) -> String { + hex::encode(digest.as_bytes()) +} + +fn domain_derivation( + output_name: &'static str, + domain: &'static str, + message: Vec, + digest: &Digest, +) -> DerivationMetadata { + DerivationMetadata { + output_name, + algorithm: "BLAKE3-256(domain || 0x00 || message)", + domain_or_context_ascii: domain, + message_hex: hex::encode(message), + expected_output_hex: digest_hex(digest), + } +} + +fn derive_key_derivation( + output_name: &'static str, + context: &'static str, + message: Vec, + digest: &Digest, +) -> DerivationMetadata { + DerivationMetadata { + output_name, + algorithm: "BLAKE3 derive_key(context, message)", + domain_or_context_ascii: context, + message_hex: hex::encode(message), + expected_output_hex: digest_hex(digest), + } +} + +fn network_request_commitment_derivation( + ack: &IdentityProtocolAck, + canonical_request: &[u8], +) -> DerivationMetadata { + let mut message = Vec::with_capacity(canonical_request.len().saturating_add(2)); + message.extend_from_slice(&ack.protocol().unwrap().code().to_be_bytes()); + message.extend_from_slice(canonical_request); + derive_key_derivation( + "network_request_commitment", + "KRIKOS-ID/network-request-commitment/v1", + message, + &ack.request_commitment(), + ) +} + +#[derive(Serialize)] +struct ProviderAnchorCommitmentPreimageMirror<'a> { + format_version: u16, + manifest: &'a ProviderCompactionManifest, +} + +fn provider_anchor_commitment_derivation( + anchor: OpaqueProviderAnchorCommitment, + manifest: &ProviderCompactionManifest, +) -> DerivationMetadata { + let message = postcard::to_stdvec(&ProviderAnchorCommitmentPreimageMirror { + format_version: 1, + manifest, + }) + .unwrap(); + domain_derivation( + "provider_anchor_commitment", + "KRIKOS-ID/provider-anchor-commitment/v1", + message, + &anchor.digest(), + ) +} + +#[derive(Serialize)] +struct ProviderChunkListCommitmentMirror<'a> { + format_version: u16, + component_code: u16, + chunk_count: u32, + commitments: &'a [Digest], +} + +fn provider_chunk_list_derivation( + output_name: &'static str, + domain: &'static str, + component_code: u16, + commitments: &[Digest], +) -> DerivationMetadata { + let message = postcard::to_stdvec(&ProviderChunkListCommitmentMirror { + format_version: 1, + component_code, + chunk_count: u32::try_from(commitments.len()).unwrap(), + commitments, + }) + .unwrap(); + let mut hasher = blake3::Hasher::new(); + hasher.update(domain.as_bytes()); + hasher.update(&[0]); + hasher.update(&message); + let digest = Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()); + domain_derivation(output_name, domain, message, &digest) +} + +const MERKLE_INTERMEDIATE_OUTPUT_NAMES: [&str; 8] = [ + "merkle_node_1", + "merkle_node_2", + "merkle_node_3", + "merkle_node_4", + "merkle_node_5", + "merkle_node_6", + "merkle_node_7", + "merkle_node_8", +]; + +fn merkle_domain_digest(domain: &str, message: &[u8]) -> Digest { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain.as_bytes()); + hasher.update(&[0]); + hasher.update(message); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) +} + +fn merkle_leaf_derivation(output_name: &'static str, leaf: &MerkleSetLeaf) -> DerivationMetadata { + let message = + postcard::to_stdvec(&(leaf.key().type_tag(), leaf.key().id(), leaf.value_hash())).unwrap(); + let digest = merkle_domain_digest("KRIKOS-ID/merkle-leaf/v1", &message); + domain_derivation(output_name, "KRIKOS-ID/merkle-leaf/v1", message, &digest) +} + +fn merkle_node_step(left: Digest, right: Digest) -> (Vec, Digest) { + let message = postcard::to_stdvec(&(left, right)).unwrap(); + let digest = merkle_domain_digest("KRIKOS-ID/merkle-node/v1", &message); + (message, digest) +} + +fn merkle_split(tree_size: u64) -> u64 { + assert!(tree_size > 1); + let mut split = 1_u64; + while split.checked_mul(2).is_some_and(|next| next < tree_size) { + split = split.checked_mul(2).unwrap(); + } + split +} + +fn merkle_inclusion_steps( + leaf_hash: Digest, + leaf_index: u64, + tree_size: u64, + audit_path: &[Digest], + path_index: &mut usize, + steps: &mut Vec<(Vec, Digest)>, +) -> Digest { + if tree_size == 1 { + assert_eq!(leaf_index, 0); + return leaf_hash; + } + let split = merkle_split(tree_size); + let (left, right) = if leaf_index < split { + let left = + merkle_inclusion_steps(leaf_hash, leaf_index, split, audit_path, path_index, steps); + let right = audit_path[*path_index]; + *path_index = path_index.checked_add(1).unwrap(); + (left, right) + } else { + let right = merkle_inclusion_steps( + leaf_hash, + leaf_index - split, + tree_size - split, + audit_path, + path_index, + steps, + ); + let left = audit_path[*path_index]; + *path_index = path_index.checked_add(1).unwrap(); + (left, right) + }; + let step = merkle_node_step(left, right); + let digest = step.1; + steps.push(step); + digest +} + +fn merkle_inclusion_derivations( + leaf: &MerkleSetLeaf, + proof: &MerkleInclusionProof, + leaf_output_name: &'static str, + root_output_name: &'static str, +) -> Vec { + let leaf_derivation = merkle_leaf_derivation(leaf_output_name, leaf); + let leaf_hash = Digest::new( + HashAlgorithm::Blake3_256, + hex::decode(&leaf_derivation.expected_output_hex) + .unwrap() + .try_into() + .unwrap(), + ); + let mut path_index = 0_usize; + let mut steps = Vec::new(); + let _root = merkle_inclusion_steps( + leaf_hash, + proof.leaf_index(), + proof.tree_size(), + proof.audit_path(), + &mut path_index, + &mut steps, + ); + assert_eq!(path_index, proof.audit_path().len()); + assert!(!steps.is_empty()); + assert!(steps.len().saturating_sub(1) <= MERKLE_INTERMEDIATE_OUTPUT_NAMES.len()); + let last = steps.len() - 1; + let mut derivations = vec![leaf_derivation]; + derivations.extend( + steps + .into_iter() + .enumerate() + .map(|(index, (message, digest))| { + let output_name = if index == last { + root_output_name + } else { + MERKLE_INTERMEDIATE_OUTPUT_NAMES[index] + }; + domain_derivation(output_name, "KRIKOS-ID/merkle-node/v1", message, &digest) + }), + ); + derivations +} + +fn merkle_consistency_derivations( + old_leaf: &MerkleSetLeaf, + proof: &MerkleConsistencyProof, +) -> Vec { + assert_eq!(proof.old_size(), 1); + assert_eq!(proof.new_size(), 3); + assert_eq!(proof.audit_path().len(), 2); + let old_root = merkle_leaf_derivation("old_merkle_root", old_leaf); + let mut current = Digest::new( + HashAlgorithm::Blake3_256, + hex::decode(&old_root.expected_output_hex) + .unwrap() + .try_into() + .unwrap(), + ); + let mut derivations = vec![old_root]; + for (index, sibling) in proof.audit_path().iter().copied().enumerate() { + let (message, digest) = merkle_node_step(current, sibling); + derivations.push(domain_derivation( + if index + 1 == proof.audit_path().len() { + "new_merkle_root" + } else { + MERKLE_INTERMEDIATE_OUTPUT_NAMES[index] + }, + "KRIKOS-ID/merkle-node/v1", + message, + &digest, + )); + current = digest; + } + derivations +} + +fn merkle_non_membership_derivations(proof: &MerkleNonMembershipProof) -> Vec { + assert!(proof.predecessor().is_none()); + let successor = proof.successor().unwrap(); + merkle_inclusion_derivations( + successor.leaf(), + successor.proof(), + "merkle_neighbor_leaf_hash", + "merkle_root", + ) +} + +#[derive(Deserialize)] +struct GenerationChunkMirror { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + generation_commitment: Digest, + component_code: u16, + ordinal: u32, + start_index: u64, + end_index: u64, + item_payload_bytes: u64, + payload: Vec, +} + +#[derive(Deserialize)] +struct ProviderCheckpointBundleMirror { + genesis: Option, + prior_checkpoint_id: Option, + events: Vec, + checkpoint: SignedCheckpoint, + transition_event: Option, +} + +#[derive(Deserialize)] +struct ProviderCheckpointBundleItemMirror { + format_version: u16, + bundle: ProviderCheckpointBundleMirror, +} + +fn chunk_items(payload: &[u8]) -> Vec> { + postcard::from_bytes(payload).expect("validated provider chunk payload must decode") +} + +fn derivations_for_account_operation(operation: &AccountOperation) -> Vec { + match operation { + AccountOperation::BeginRecovery(begin) => derivations_for_wire_type( + "RecoveryProposal", + &begin.proposal().to_canonical_bytes().unwrap(), + ), + AccountOperation::ResolveFork(resolve) => derivations_for_wire_type( + "ForkDescriptor", + &resolve.fork().to_canonical_bytes().unwrap(), + ), + AccountOperation::BeginCryptoMigration(begin) => { + derivations_for_wire_type("BeginCryptoMigration", &begin.to_canonical_bytes().unwrap()) + } + _ => Vec::new(), + } +} + +fn derivations_for_wire_type(wire_type: &str, bytes: &[u8]) -> Vec { + match wire_type { + "AccountGenesis" => { + let value = AccountGenesis::from_canonical_bytes(bytes).unwrap(); + vec![ + domain_derivation( + "account_id", + "KRIKOS-ID/account-id/v1", + bytes.to_vec(), + value.account_id().unwrap().as_digest(), + ), + domain_derivation( + "genesis_anchor", + "KRIKOS-ID/genesis-anchor/v1", + bytes.to_vec(), + value.genesis_anchor().unwrap().as_digest(), + ), + ] + } + "EventBody" => { + let value = EventBody::from_canonical_bytes(bytes).unwrap(); + let mut derivations = vec![domain_derivation( + "proposal_id", + "KRIKOS-ID/account-proposal/v1", + bytes.to_vec(), + value.proposal_id().unwrap().as_digest(), + )]; + derivations.extend(derivations_for_account_operation(value.operation())); + derivations + } + "AccountOperation" => { + let value = AccountOperation::from_canonical_bytes(bytes).unwrap(); + derivations_for_account_operation(&value) + } + "AdmissionEvidence" => { + let value = AdmissionEvidence::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "admission_evidence_id", + "KRIKOS-ID/admission-evidence/v1", + bytes.to_vec(), + value.admission_evidence_id().unwrap().as_digest(), + )] + } + "AuthorizedEvent" => { + let value = AuthorizedEvent::from_canonical_bytes(bytes).unwrap(); + let body_bytes = value.body().to_canonical_bytes().unwrap(); + let evidence_bytes = value.admission_evidence().to_canonical_bytes().unwrap(); + let evidence_id = value.admission_evidence().admission_evidence_id().unwrap(); + let admitted_message = postcard::to_stdvec(&(value.body(), evidence_id)).unwrap(); + let mut derivations = derivations_for_wire_type("EventBody", &body_bytes); + derivations.extend([ + domain_derivation( + "admission_evidence_id", + "KRIKOS-ID/admission-evidence/v1", + evidence_bytes, + evidence_id.as_digest(), + ), + domain_derivation( + "event_id", + "KRIKOS-ID/account-event/v1", + admitted_message, + value.event_id().unwrap().as_digest(), + ), + domain_derivation( + "event_authorization_id", + "KRIKOS-ID/event-authorization/v1", + bytes.to_vec(), + value.event_authorization_id().unwrap().as_digest(), + ), + ]); + derivations + } + "SignedCheckpoint" => { + let value = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "checkpoint_id", + "KRIKOS-ID/account-checkpoint/v1", + value.body().to_canonical_bytes().unwrap(), + value.checkpoint_id().unwrap().as_digest(), + )] + } + "BeginCryptoMigration" => { + let value = BeginCryptoMigration::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "crypto_migration_id", + "KRIKOS-ID/crypto-migration/v1", + value.migration().to_canonical_bytes().unwrap(), + value.migration().crypto_migration_id().unwrap().as_digest(), + )] + } + "EventIntentApprovalBody" => { + let value = EventIntentApprovalBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "event_intent_approval_id", + "KRIKOS-ID/event-intent-approval/v1", + bytes.to_vec(), + value.event_intent_approval_id().unwrap().as_digest(), + )] + } + "ControllerApprovalBody" => { + let value = ControllerApprovalBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "controller_approval_id", + "KRIKOS-ID/controller-approval/v1", + bytes.to_vec(), + value.controller_approval_id().unwrap().as_digest(), + )] + } + "RecoveryAuthorityPlan" => { + let plan = RecoveryAuthorityPlan::from_canonical_bytes(bytes).unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()) + .unwrap(); + vec![domain_derivation( + "recovery_id", + "KRIKOS-ID/recovery/v1", + proposal.to_canonical_bytes().unwrap(), + proposal.recovery_id().unwrap().as_digest(), + )] + } + "RecoveryProposal" => { + let value = RecoveryProposal::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "recovery_id", + "KRIKOS-ID/recovery/v1", + bytes.to_vec(), + value.recovery_id().unwrap().as_digest(), + )] + } + "BeginRecovery" => { + let value = BeginRecovery::from_canonical_bytes(bytes).unwrap(); + derivations_for_wire_type( + "RecoveryProposal", + &value.proposal().to_canonical_bytes().unwrap(), + ) + } + "ForkDescriptor" => { + let value = ForkDescriptor::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "fork_id", + "KRIKOS-ID/fork/v1", + postcard::to_stdvec(&(value.common_ancestor(), value.heads())).unwrap(), + value.fork_id().unwrap().as_digest(), + )] + } + "CapabilityGrant" => { + let value = CapabilityGrant::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "capability_grant_id", + "KRIKOS-ID/capability-grant/v1", + bytes.to_vec(), + value.capability_grant_id().unwrap().as_digest(), + )] + } + "DelegationBody" => { + let value = DelegationBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "delegation_id", + "KRIKOS-ID/capability-delegation/v1", + bytes.to_vec(), + value.delegation_id().unwrap().as_digest(), + )] + } + "SignedApplicationEvent" => { + let value = SignedApplicationEvent::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "application_event_id", + "KRIKOS-ID/application-event/v1", + bytes.to_vec(), + value.application_event_id().unwrap().as_digest(), + )] + } + "WrappedGroupKey" => { + let value = WrappedGroupKey::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "group_key_wrap_id", + "KRIKOS-ID/group-key-wrap/v1", + bytes.to_vec(), + value.group_key_wrap_id().unwrap().as_digest(), + )] + } + "ProviderLogEntryBody" => { + let value = ProviderLogEntryBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "merkle_leaf_hash", + "KRIKOS-ID/provider-log-entry/v1", + bytes.to_vec(), + &value.merkle_leaf_hash().unwrap(), + )] + } + "MerkleSetLeaf" => { + let value = MerkleSetLeaf::from_canonical_bytes(bytes).unwrap(); + vec![merkle_leaf_derivation("merkle_leaf_hash", &value)] + } + "MerkleNonMembershipProof" => { + let value = MerkleNonMembershipProof::from_canonical_bytes(bytes).unwrap(); + merkle_non_membership_derivations(&value) + } + "PairingTicket" => { + let value = PairingTicket::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "pairing_ticket_id", + "KRIKOS-ID/pairing-ticket-id/v1", + bytes.to_vec(), + value.ticket_id().unwrap().as_digest(), + )] + } + "PairingTranscript" => { + let value = PairingTranscript::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "pairing_transcript_id", + "KRIKOS-ID/pairing-transcript-id/v1", + bytes.to_vec(), + value.transcript_id().unwrap().as_digest(), + )] + } + "PairingPossessionProof" => { + let value = PairingPossessionProof::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "pairing_proof_id", + "KRIKOS-ID/pairing-possession-proof-id/v1", + bytes.to_vec(), + value.proof_id().unwrap().as_digest(), + )] + } + "DeviceAuthorizationProposal" => { + let value = DeviceAuthorizationProposal::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "device_authorization_proposal_id", + "KRIKOS-ID/device-authorization-proposal-id/v1", + bytes.to_vec(), + value.proposal_id().unwrap().as_digest(), + )] + } + "PresenceProof" => { + let value = PresenceProof::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "presence_proof_id", + "KRIKOS-ID/device-presence-proof-id/v1", + bytes.to_vec(), + value.proof_id().unwrap().as_digest(), + )] + } + "BackupAuthorityBundle" => { + let value = BackupAuthorityBundle::from_canonical_bytes(bytes).unwrap(); + let mut derivations = derivations_for_wire_type( + "AccountGenesis", + &value.genesis().to_canonical_bytes().unwrap(), + ); + for event in value.events() { + derivations.extend(derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + )); + } + derivations.extend(derivations_for_wire_type( + "SignedCheckpoint", + &value.checkpoint().to_canonical_bytes().unwrap(), + )); + derivations + } + "ProviderGenerationExportChunk" => { + let value = ProviderGenerationExportChunk::from_canonical_bytes(bytes).unwrap(); + let chunk_commitment = value.commitment().unwrap(); + let mut derivations = vec![ + domain_derivation( + "provider_generation_chunk_commitment", + "KRIKOS-ID/provider-generation-chunk/v1", + bytes.to_vec(), + &chunk_commitment, + ), + provider_chunk_list_derivation( + "provider_generation_chunk_list_commitment", + "KRIKOS-ID/provider-generation-chunk-list/v1", + value.component().unwrap().code(), + &[chunk_commitment], + ), + ]; + let mirror: GenerationChunkMirror = postcard::from_bytes(bytes).unwrap(); + assert_eq!(mirror.format_version, 1); + assert_eq!(mirror.provider_id, value.provider_id()); + assert_eq!(mirror.log_id, value.log_id()); + assert_eq!(mirror.key_version, value.key_version()); + assert_eq!(mirror.generation_commitment, value.generation_commitment()); + assert_eq!(mirror.component_code, value.component().unwrap().code()); + assert_eq!(mirror.ordinal, value.ordinal()); + assert_eq!(mirror.start_index, value.start_index()); + assert_eq!(mirror.end_index, value.end_index()); + assert_eq!(mirror.item_payload_bytes, value.item_payload_bytes()); + if value.component() == Ok(ProviderExportComponent::CheckpointBundles) { + for item in chunk_items(&mirror.payload) { + let item: ProviderCheckpointBundleItemMirror = + postcard::from_bytes(&item).unwrap(); + assert_eq!(item.format_version, 1); + if let Some(genesis) = &item.bundle.genesis { + derivations.extend(derivations_for_wire_type( + "AccountGenesis", + &genesis.to_canonical_bytes().unwrap(), + )); + } + let _prior_checkpoint_id = item.bundle.prior_checkpoint_id; + for event in &item.bundle.events { + derivations.extend(derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + )); + } + derivations.extend(derivations_for_wire_type( + "SignedCheckpoint", + &item.bundle.checkpoint.to_canonical_bytes().unwrap(), + )); + if let Some(event) = &item.bundle.transition_event { + derivations.extend(derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + )); + } + } + } + derivations + } + "ProviderAuditExportChunk" => { + let value = ProviderAuditExportChunk::from_canonical_bytes(bytes).unwrap(); + let chunk_commitment = value.commitment().unwrap(); + vec![ + domain_derivation( + "provider_audit_chunk_commitment", + "KRIKOS-ID/provider-audit-chunk/v1", + bytes.to_vec(), + &chunk_commitment, + ), + provider_chunk_list_derivation( + "provider_audit_chunk_list_commitment", + "KRIKOS-ID/provider-audit-chunk-list/v1", + 0, + &[chunk_commitment], + ), + ] + } + "ProviderGenerationExportManifest" => { + let value = ProviderGenerationExportManifest::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "provider_generation_manifest_commitment", + "KRIKOS-ID/provider-generation-manifest/v1", + bytes.to_vec(), + &value.commitment().unwrap(), + )] + } + "ProviderAuditExportManifest" => { + let value = ProviderAuditExportManifest::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "provider_audit_manifest_commitment", + "KRIKOS-ID/provider-audit-manifest/v1", + bytes.to_vec(), + &value.commitment().unwrap(), + )] + } + "ProviderRecoveryExportManifest" => { + let value = ProviderRecoveryExportManifest::from_canonical_bytes(bytes).unwrap(); + let mut derivations = vec![domain_derivation( + "provider_recovery_manifest_commitment", + "KRIKOS-ID/provider-recovery-manifest/v1", + bytes.to_vec(), + &value.commitment().unwrap(), + )]; + derivations.extend(derivations_for_wire_type( + "ProviderGenerationExportManifest", + &value.generation().to_canonical_bytes().unwrap(), + )); + derivations.extend(derivations_for_wire_type( + "ProviderAuditExportManifest", + &value.audit().to_canonical_bytes().unwrap(), + )); + derivations + } + "SyncFrame" => { + let value = SyncFrame::from_canonical_bytes(bytes).unwrap(); + value + .events() + .iter() + .flat_map(|event| { + derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + ) + }) + .collect() + } + "SyncResponse" => SyncResponse::from_canonical_bytes(bytes) + .unwrap() + .as_frame() + .map_or_else(Vec::new, |frame| { + derivations_for_wire_type("SyncFrame", &frame.to_canonical_bytes().unwrap()) + }), + "AuthorizedProposalRequest" => { + let value = AuthorizedProposalRequest::from_canonical_bytes(bytes).unwrap(); + derivations_for_wire_type( + "DeviceAuthorizationProposal", + &value.proposal().to_canonical_bytes().unwrap(), + ) + } + "AuthorizedCheckpointRequest" => { + let value = AuthorizedCheckpointRequest::from_canonical_bytes(bytes).unwrap(); + derivations_for_wire_type( + "SignedCheckpoint", + &value.checkpoint().to_canonical_bytes().unwrap(), + ) + } + "IdentityProtocolReply" => IdentityProtocolReply::from_canonical_bytes(bytes) + .unwrap() + .as_sync() + .map_or_else(Vec::new, |response| { + derivations_for_wire_type("SyncResponse", &response.to_canonical_bytes().unwrap()) + }), + _ => Vec::new(), + } +} + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn add_digest_ids(catalog: &mut Catalog) { + macro_rules! add_id { + ($name:literal, $type:ty, $seed:expr) => {{ + let value = typed_id::<$type>($seed); + catalog.add( + $name, + stringify!($type), + &value, + VectorDetails { + protocol_version: None, + version_scope: + "standalone-algorithm-tagged-digest; version inherited from enclosing v1 object", + ..VectorDetails::default() + }, + ); + }}; + } + + add_id!("id-genesis-anchor", GenesisAnchor, 0x01); + add_id!("id-account", AccountId, 0x02); + add_id!("id-controller", ControllerId, 0x03); + add_id!("id-controller-key", ControllerKeyId, 0x04); + add_id!("id-control-policy", ControlPolicyId, 0x05); + add_id!("id-recovery-policy", RecoveryPolicyId, 0x06); + add_id!("id-provider", ProviderId, 0x07); + add_id!("id-provider-log", ProviderLogId, 0x08); + add_id!("id-provider-policy", ProviderPolicyId, 0x09); + add_id!("id-device", DeviceId, 0x0a); + add_id!("id-capability-grant", CapabilityGrantId, 0x0b); + add_id!("id-delegation", DelegationId, 0x0c); + add_id!("id-proposal", ProposalId, 0x0d); + add_id!("id-event", EventId, 0x0e); + add_id!("id-event-authorization", EventAuthorizationId, 0x0f); + add_id!("id-admission-evidence", AdmissionEvidenceId, 0x10); + add_id!("id-controller-approval", ControllerApprovalId, 0x11); + add_id!("id-event-intent-approval", EventIntentApprovalId, 0x12); + add_id!("id-checkpoint", CheckpointId, 0x13); + add_id!("id-recovery", RecoveryId, 0x14); + add_id!("id-guardian-grant", GuardianGrantId, 0x15); + add_id!("id-fork", ForkId, 0x16); + add_id!("id-crypto-suite", CryptoSuiteId, 0x17); + add_id!("id-crypto-migration", CryptoMigrationId, 0x18); + add_id!("id-crypto-state", CryptoStateId, 0x19); + add_id!("id-application", ApplicationId, 0x1a); + add_id!("id-application-event", ApplicationEventId, 0x1b); + add_id!("id-group", GroupId, 0x1c); + add_id!("id-group-key-wrap", GroupKeyWrapId, 0x1d); +} + +fn signature_details( + domain: &'static str, + message: Vec, + secret: &SecretKey, + signature: [u8; 64], +) -> VectorDetails { + let binding = signature_binding("signature-1", domain, message, secret, signature); + VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![binding], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + } +} + +fn signature_binding( + name: &str, + domain: &'static str, + message: Vec, + secret: &SecretKey, + signature: [u8; 64], +) -> SignatureBinding { + let signer_key = match secret.public().as_bytes() { + bytes if bytes == SecretKey::from_bytes(&[0x01; 32]).public().as_bytes() => "guardian-1", + bytes if bytes == SecretKey::from_bytes(&[0x02; 32]).public().as_bytes() => "guardian-2", + bytes if bytes == SecretKey::from_bytes(&[0x0a; 32]).public().as_bytes() => { + "pairing-presence-application" + } + bytes if bytes == SecretKey::from_bytes(&[0x0c; 32]).public().as_bytes() => { + "pairing-endpoint" + } + bytes if bytes == SecretKey::from_bytes(&[0x11; 32]).public().as_bytes() => { + "account-controller-and-social-issuer" + } + bytes if bytes == SecretKey::from_bytes(&[0x31; 32]).public().as_bytes() => { + "application-device" + } + bytes if bytes == SecretKey::from_bytes(&[0x35; 32]).public().as_bytes() => { + "capability-delegator" + } + bytes if bytes == SecretKey::from_bytes(&[0x41; 32]).public().as_bytes() => { + "name-and-portable-credential-issuer" + } + bytes if bytes == SecretKey::from_bytes(&[0x71; 32]).public().as_bytes() => { + "transparency-provider" + } + bytes if bytes == SecretKey::from_bytes(&[0x91; 32]).public().as_bytes() => { + "migration-successor-controller" + } + _ => panic!("every deterministic signer must have a named manifest key"), + }; + SignatureBinding { + name: name.to_owned(), + algorithm: "Ed25519", + domain_ascii: domain, + message_hex: hex::encode(message), + signer_key, + public_key_hex: hex::encode(secret.public().as_bytes()), + signature_hex: hex::encode(signature), + } +} + +fn interop_crypto_migration() -> (BeginCryptoMigration, CryptoMigrationId) { + let old_secret = SecretKey::from_bytes(&[0x11; 32]); + let new_secret = SecretKey::from_bytes(&[0x91; 32]); + let old_signing_key = SigningPublicKey::ed25519(*old_secret.public().as_bytes()).unwrap(); + let binding = ControllerKeyBinding::try_new( + typed_id::(41), + ControllerKeyId::for_signing_key(&old_signing_key).unwrap(), + AlgorithmPublicKey::new( + SignatureAlgorithm::Ed25519.code(), + new_secret.public().as_bytes().to_vec(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let migration = CryptoMigrationBody::try_new( + ProtocolVersion::V1, + typed_id::(1), + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 2, + HashAlgorithm::Blake3_256.code(), + SignatureAlgorithm::Ed25519.code(), + AgreementAlgorithm::X25519.code(), + KdfAlgorithm::Blake3DeriveKey.code(), + AeadAlgorithm::XChaCha20Poly1305.code(), + Extensions::default(), + ) + .unwrap(), + vec![binding], + None, + [45; 32], + Extensions::default(), + ) + .unwrap(); + let migration_id = migration.crypto_migration_id().unwrap(); + let message = migration_id.to_canonical_bytes().unwrap(); + let proof = ControllerKeyBindingProof::try_new( + migration_id, + typed_id::(41), + AlgorithmSignature::new( + SignatureAlgorithm::Ed25519.code(), + old_secret.sign(&message).to_bytes().to_vec(), + ) + .unwrap(), + AlgorithmSignature::new( + SignatureAlgorithm::Ed25519.code(), + new_secret.sign(&message).to_bytes().to_vec(), + ) + .unwrap(), + ) + .unwrap(); + let begin = BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + ControllerKeyBindingProofSet::try_new(vec![proof]).unwrap(), + Extensions::default(), + ) + .unwrap(); + (begin, migration_id) +} + +fn crypto_migration_signature_bindings(migration: &BeginCryptoMigration) -> Vec { + let old_secret = SecretKey::from_bytes(&[0x11; 32]); + let new_secret = SecretKey::from_bytes(&[0x91; 32]); + let message = migration + .migration() + .crypto_migration_id() + .unwrap() + .to_canonical_bytes() + .unwrap(); + migration + .migration() + .bindings() + .iter() + .zip(migration.proofs().as_slice()) + .flat_map(|(binding, proof)| { + assert_eq!(binding.controller_id(), proof.controller_id()); + assert_eq!( + binding.old_key_id(), + ControllerKeyId::for_signing_key( + &SigningPublicKey::ed25519(*old_secret.public().as_bytes()).unwrap(), + ) + .unwrap() + ); + assert_eq!( + binding.new_signing_key().as_bytes(), + new_secret.public().as_bytes() + ); + [ + signature_binding( + "migration-old-key-signature", + "none", + message.clone(), + &old_secret, + proof.old_key_signature().as_bytes().try_into().unwrap(), + ), + signature_binding( + "migration-new-key-signature", + "none", + message.clone(), + &new_secret, + proof.new_key_signature().as_bytes().try_into().unwrap(), + ), + ] + }) + .enumerate() + .map(|(index, mut binding)| { + binding.name = format!("signature-{}", index + 1); + binding + }) + .collect() +} + +fn interop_finalize_recovery(template: &FinalizeRecovery) -> FinalizeRecovery { + let provider_secret = SecretKey::from_bytes(&[0x71; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let template_anchor = template.delay_anchor(); + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + typed_id::(32), + template_anchor.account_id(), + ProviderLogSubject::EventIntent(template_anchor.begin_proposal_id()), + template_anchor.observed_at(), + Extensions::default(), + ) + .unwrap(); + let head_body = ProviderHeadBody::new( + provider.id().unwrap(), + entry.log_id(), + ProviderKeyVersion::GENESIS, + 1, + entry.merkle_leaf_hash().unwrap(), + Timestamp::from_unix_millis(template_anchor.observed_at().as_unix_millis() + 1), + Extensions::default(), + ) + .unwrap(); + let head = SignedProviderHead::new( + head_body.clone(), + ProtocolSignature::ed25519( + provider_secret + .sign(&head_body.signing_bytes().unwrap()) + .to_bytes(), + ), + ); + let receipt = InclusionReceipt::new(entry, 0, Vec::new(), head).unwrap(); + receipt.verify(&provider).unwrap(); + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + template_anchor.account_id(), + template_anchor.recovery_id(), + template_anchor.begin_proposal_id(), + template_anchor.provider_policy_id(), + template_anchor.required_quorum(), + ProviderReceipts::new(vec![receipt]).unwrap(), + Extensions::default(), + ) + .unwrap(); + FinalizeRecovery::try_new( + ProtocolVersion::V1, + template.expected_pending_recovery(), + anchor, + template.finalized_at(), + Extensions::default(), + ) + .unwrap() +} + +fn recovery_delay_signature_bindings(anchor: &RecoveryDelayAnchor) -> Vec { + let provider_secret = SecretKey::from_bytes(&[0x71; 32]); + anchor + .receipts() + .as_slice() + .iter() + .enumerate() + .map(|(index, receipt)| { + let head = receipt.signed_head(); + signature_binding( + &format!("signature-{}", index + 1), + "KRIKOS-ID/provider-head-signature/v1", + head.body().signing_bytes().unwrap(), + &provider_secret, + *head.signature().as_bytes(), + ) + }) + .collect() +} + +struct PairingMacKeyInputs { + secret_seed: [u8; 32], + subject_public_key: AgreementPublicKey, + connection_public_key: AgreementPublicKey, +} + +fn pairing_mac_binding( + name: &'static str, + key_context: &'static str, + message_domain: &'static str, + key_inputs: PairingMacKeyInputs, + transcript_bytes: &[u8], + expected_mac: &[u8; 32], +) -> MacBinding { + let secret = StaticSecret::from(key_inputs.secret_seed); + let connection_public = X25519PublicKey::from(*key_inputs.connection_public_key.as_bytes()); + let shared = secret.diffie_hellman(&connection_public); + let mut key_material = [0_u8; 96]; + key_material[..32].copy_from_slice(shared.as_bytes()); + key_material[32..64].copy_from_slice(key_inputs.subject_public_key.as_bytes()); + key_material[64..].copy_from_slice(key_inputs.connection_public_key.as_bytes()); + let key = blake3::derive_key(key_context, &key_material); + let mut message = Vec::with_capacity(message_domain.len() + 1 + transcript_bytes.len()); + message.extend_from_slice(message_domain.as_bytes()); + message.push(0); + message.extend_from_slice(transcript_bytes); + assert_eq!(blake3::keyed_hash(&key, &message).as_bytes(), expected_mac); + MacBinding { + name: name.to_owned(), + algorithm: "BLAKE3 keyed_hash(key, message)", + key_derivation_algorithm: "BLAKE3 derive_key(context, input)", + key_derivation_context_ascii: key_context, + key_derivation_input_hex: hex::encode(key_material), + message_domain_ascii: message_domain, + message_hex: hex::encode(message), + expected_mac_hex: hex::encode(expected_mac), + } +} + +const INTEROP_SYNC_CURSOR_KEY: [u8; 32] = [0x51; 32]; + +#[derive(Deserialize)] +struct SyncCursorMacMirror { + protocol_version: ProtocolVersion, + account_id: AccountId, + source_heads: Vec, + next_item: u64, + delivered_bytes: u64, + authenticator: [u8; 32], +} + +fn sync_cursor_mac_binding(name: &str, cursor: &SyncCursor) -> MacBinding { + let encoded = cursor.to_canonical_bytes().unwrap(); + let mirror: SyncCursorMacMirror = postcard::from_bytes(&encoded).unwrap(); + assert_eq!(mirror.protocol_version, ProtocolVersion::V1); + assert_eq!(mirror.account_id, cursor.account_id()); + assert_eq!(mirror.source_heads, cursor.source_heads()); + assert_eq!(mirror.next_item, cursor.next_item()); + assert_eq!(mirror.delivered_bytes, cursor.delivered_bytes()); + let message = postcard::to_stdvec(&( + ProtocolVersion::V1, + cursor.account_id(), + cursor.source_heads(), + cursor.next_item(), + cursor.delivered_bytes(), + )) + .unwrap(); + let expected = blake3::keyed_hash(&INTEROP_SYNC_CURSOR_KEY, &message); + assert_eq!(expected.as_bytes(), &mirror.authenticator); + cursor + .verify(&CursorKey::new(INTEROP_SYNC_CURSOR_KEY).unwrap()) + .unwrap(); + MacBinding { + name: name.to_owned(), + algorithm: "BLAKE3 keyed_hash(key, message)", + key_derivation_algorithm: "raw 256-bit test key", + key_derivation_context_ascii: "none", + key_derivation_input_hex: hex::encode(INTEROP_SYNC_CURSOR_KEY), + message_domain_ascii: "none", + message_hex: hex::encode(message), + expected_mac_hex: hex::encode(mirror.authenticator), + } +} + +fn output_directory() -> PathBuf { + let mut arguments = env::args_os(); + let _executable = arguments.next(); + let directory = arguments.next().map_or_else( + || Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors"), + PathBuf::from, + ); + assert!( + arguments.next().is_none(), + "usage: generate_interop_vectors [OUTPUT_DIRECTORY]" + ); + directory +} + +fn fuzz_seed_payload(filename: &str, expected_selector: u8) -> Vec { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fuzz/corpus/identity_pairing") + .join(filename); + let text = fs::read_to_string(path).unwrap(); + let (selector, hexadecimal) = text.split_once("hex:").unwrap(); + assert_eq!(selector.trim().parse::().unwrap(), expected_selector); + let compact = hexadecimal + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + hex::decode(compact).unwrap() +} + +fn main() { + let directory = output_directory(); + fs::create_dir_all(&directory).unwrap(); + for obsolete in [ + "provider-generation-export.bin", + "provider-recovery-export.bin", + ] { + let path = directory.join(obsolete); + if path.exists() { + fs::remove_file(path).unwrap(); + } + } + let mut catalog = Catalog::new(directory.clone()); + add_digest_ids(&mut catalog); + + let fixture = backup::fixture(); + let mut genesis_ids = BTreeMap::new(); + genesis_ids.insert( + "account_id", + fixture.genesis.account_id().unwrap().to_string(), + ); + genesis_ids.insert( + "genesis_anchor", + fixture.genesis.genesis_anchor().unwrap().to_string(), + ); + catalog.add( + "account-genesis", + "AccountGenesis", + &fixture.genesis, + VectorDetails { + expected_ids: genesis_ids, + ..VectorDetails::default() + }, + ); + + let intent_body = EventIntentApprovalBody::new( + fixture.event.approvals().as_slice()[0] + .body() + .controller_id(), + fixture.event.body().proposal_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let intent_signature = fixture + .signer + .sign(&intent_body.to_canonical_bytes().unwrap()) + .to_bytes(); + let account_signing_key = + SigningPublicKey::ed25519(*fixture.signer.public().as_bytes()).unwrap(); + let intent = SignedEventIntentApproval::new( + intent_body.clone(), + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&account_signing_key).unwrap(), + AlgorithmSignature::new(1, intent_signature.to_vec()).unwrap(), + )], + ) + .unwrap(); + let intents = EventIntentApprovals::new(vec![intent.clone()]).unwrap(); + catalog.add( + "event-intent-approval-body", + "EventIntentApprovalBody", + &intent_body, + VectorDetails { + expected_ids: BTreeMap::from([( + "event_intent_approval_id", + intent_body.event_intent_approval_id().unwrap().to_string(), + )]), + ..VectorDetails::default() + }, + ); + catalog.add( + "event-intent-approval", + "SignedEventIntentApproval", + &intent, + VectorDetails { + dependencies: vec!["event-intent-approval-body".to_owned()], + ..signature_details( + "KRIKOS-ID/event-intent-approval-signature/v1", + intent_body.to_canonical_bytes().unwrap(), + &fixture.signer, + intent_signature, + ) + }, + ); + catalog.add( + "event-intent-approvals", + "EventIntentApprovals", + &intents, + VectorDetails { + dependencies: vec!["event-intent-approval".to_owned()], + ..signature_details( + "KRIKOS-ID/event-intent-approval-signature/v1", + intent_body.to_canonical_bytes().unwrap(), + &fixture.signer, + intent_signature, + ) + }, + ); + + let event = &fixture.event; + let mut event_ids = BTreeMap::new(); + event_ids.insert( + "proposal_id", + event.body().proposal_id().unwrap().to_string(), + ); + event_ids.insert( + "admission_evidence_id", + event + .admission_evidence() + .admission_evidence_id() + .unwrap() + .to_string(), + ); + event_ids.insert("event_id", event.event_id().unwrap().to_string()); + event_ids.insert( + "event_authorization_id", + event.event_authorization_id().unwrap().to_string(), + ); + catalog.add( + "event-body", + "EventBody", + event.body(), + VectorDetails { + expected_ids: BTreeMap::from([( + "proposal_id", + event.body().proposal_id().unwrap().to_string(), + )]), + ..VectorDetails::default() + }, + ); + catalog.add( + "admission-evidence", + "AdmissionEvidence", + event.admission_evidence(), + VectorDetails { + expected_ids: BTreeMap::from([( + "admission_evidence_id", + event + .admission_evidence() + .admission_evidence_id() + .unwrap() + .to_string(), + )]), + dependencies: vec!["event-body".to_owned()], + ..VectorDetails::default() + }, + ); + let final_approval = &event.approvals().as_slice()[0]; + let final_signature = final_approval.signatures()[0] + .signature() + .as_bytes() + .try_into() + .unwrap(); + catalog.add( + "final-event-controller-approval-body", + "ControllerApprovalBody", + final_approval.body(), + VectorDetails { + expected_ids: BTreeMap::from([( + "controller_approval_id", + final_approval + .body() + .controller_approval_id() + .unwrap() + .to_string(), + )]), + dependencies: vec!["event-body".to_owned(), "admission-evidence".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "final-event-controller-approval", + "SignedControllerApproval", + final_approval, + VectorDetails { + dependencies: vec!["final-event-controller-approval-body".to_owned()], + ..signature_details( + "KRIKOS-ID/controller-approval-signature/v1", + final_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + final_signature, + ) + }, + ); + catalog.add( + "controller-approvals", + "ControllerApprovals", + event.approvals(), + VectorDetails { + dependencies: vec!["final-event-controller-approval".to_owned()], + ..signature_details( + "KRIKOS-ID/controller-approval-signature/v1", + final_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + final_signature, + ) + }, + ); + catalog.add( + "authorized-event", + "AuthorizedEvent", + event, + VectorDetails { + expected_ids: event_ids, + dependencies: vec![ + "event-body".to_owned(), + "admission-evidence".to_owned(), + "final-event-controller-approval".to_owned(), + ], + ..signature_details( + "KRIKOS-ID/controller-approval-signature/v1", + final_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + final_signature, + ) + }, + ); + + let direct_checkpoint_approval = &fixture + .checkpoint + .authorization() + .controller_approvals() + .unwrap() + .as_slice()[0]; + let direct_checkpoint_signature: [u8; 64] = direct_checkpoint_approval.signatures()[0] + .signature() + .as_bytes() + .try_into() + .unwrap(); + catalog.add( + "checkpoint-direct", + "SignedCheckpoint", + &fixture.checkpoint, + VectorDetails { + expected_ids: BTreeMap::from([( + "checkpoint_id", + fixture.checkpoint.checkpoint_id().unwrap().to_string(), + )]), + ..signature_details( + "KRIKOS-ID/controller-approval-signature/v1", + direct_checkpoint_approval + .body() + .to_canonical_bytes() + .unwrap(), + &fixture.signer, + direct_checkpoint_signature, + ) + }, + ); + let (finalize_checkpoint, retire_checkpoint) = task2::transition_checkpoints(); + for (name, checkpoint) in [ + ("checkpoint-transition-finalize", finalize_checkpoint), + ("checkpoint-transition-retire", retire_checkpoint), + ] { + catalog.add( + name, + "SignedCheckpoint", + &checkpoint, + VectorDetails { + expected_ids: BTreeMap::from([( + "checkpoint_id", + checkpoint.checkpoint_id().unwrap().to_string(), + )]), + tamper_expectation: "identifier_or_binding_rejected", + ..VectorDetails::default() + }, + ); + } + let (pending_checkpoint, dual_checkpoint) = backup::migration_checkpoints(); + for (name, checkpoint) in [ + ("checkpoint-migration-pending", pending_checkpoint), + ("checkpoint-migration-dual", dual_checkpoint), + ] { + let approval = &checkpoint + .authorization() + .controller_approvals() + .unwrap() + .as_slice()[0]; + let signature: [u8; 64] = approval.signatures()[0] + .signature() + .as_bytes() + .try_into() + .unwrap(); + catalog.add( + name, + "SignedCheckpoint", + &checkpoint, + VectorDetails { + expected_ids: BTreeMap::from([( + "checkpoint_id", + checkpoint.checkpoint_id().unwrap().to_string(), + )]), + ..signature_details( + "KRIKOS-ID/controller-approval-signature/v1", + approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + signature, + ) + }, + ); + } + catalog.add( + "backup-authority-bundle", + "BackupAuthorityBundle", + &fixture.bundle, + VectorDetails { + dependencies: vec![ + "account-genesis".to_owned(), + "authorized-event".to_owned(), + "checkpoint-direct".to_owned(), + ], + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![ + signature_binding( + "signature-1", + "KRIKOS-ID/controller-approval-signature/v1", + final_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + final_signature, + ), + signature_binding( + "signature-2", + "KRIKOS-ID/controller-approval-signature/v1", + direct_checkpoint_approval + .body() + .to_canonical_bytes() + .unwrap(), + &fixture.signer, + direct_checkpoint_signature, + ), + ], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "backup-envelope", + "BackupEnvelope", + &fixture.envelope, + VectorDetails { + algorithms: vec!["Argon2id", "XChaCha20-Poly1305", "BLAKE3-256"], + dependencies: vec!["backup-authority-bundle".to_owned()], + tamper_expectation: "authentication_or_decode_rejected", + ..VectorDetails::default() + }, + ); + + let (proposal, begin, veto, cancel, template_finalize, _, _, _, _) = task2::recovery_values(); + let finalize = interop_finalize_recovery(&template_finalize); + let delay_anchor = finalize.delay_anchor().clone(); + let recovery_signatures = recovery_delay_signature_bindings(&delay_anchor); + let (migration, migration_id) = interop_crypto_migration(); + let migration_signatures = crypto_migration_signature_bindings(&migration); + let (recovery_plan, _, fork) = task2::recovery_plan_anchor_and_fork(); + let mut account_operations = task2::operations(); + for operation in &mut account_operations { + match operation { + AccountOperation::BeginRecovery(_) => { + *operation = AccountOperation::BeginRecovery(begin.clone()); + } + AccountOperation::VetoRecovery(_) => { + *operation = AccountOperation::VetoRecovery(veto.clone()); + } + AccountOperation::CancelRecovery(_) => { + *operation = AccountOperation::CancelRecovery(cancel.clone()); + } + AccountOperation::FinalizeRecovery(_) => { + *operation = AccountOperation::FinalizeRecovery(finalize.clone()); + } + AccountOperation::ResolveFork(_) => { + *operation = AccountOperation::ResolveFork( + ResolveFork::try_new( + ProtocolVersion::V1, + fork.clone(), + fork.heads()[0], + vec![typed_id::(51)], + vec![typed_id::(52)], + Extensions::default(), + ) + .unwrap(), + ); + } + AccountOperation::BeginCryptoMigration(_) => { + *operation = AccountOperation::BeginCryptoMigration(migration.clone()); + } + _ => {} + } + } + for (index, operation) in account_operations.iter().enumerate() { + let code = index + 1; + assert_eq!(usize::from(operation.kind().code()), code); + let details = match operation { + AccountOperation::BeginRecovery(_) => VectorDetails { + dependencies: vec!["recovery-begin".to_owned()], + ..VectorDetails::default() + }, + AccountOperation::VetoRecovery(_) => VectorDetails { + dependencies: vec!["recovery-veto".to_owned()], + ..VectorDetails::default() + }, + AccountOperation::CancelRecovery(_) => VectorDetails { + dependencies: vec!["recovery-cancel".to_owned()], + ..VectorDetails::default() + }, + AccountOperation::FinalizeRecovery(_) => VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: recovery_signatures.clone(), + dependencies: vec!["recovery-finalize".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + AccountOperation::ResolveFork(_) => VectorDetails { + dependencies: vec!["fork-descriptor".to_owned()], + ..VectorDetails::default() + }, + AccountOperation::BeginCryptoMigration(_) => VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: migration_signatures.clone(), + dependencies: vec!["crypto-migration-begin".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + _ => VectorDetails::default(), + }; + catalog.add( + format!("account-operation-{code:02}"), + "AccountOperation", + operation, + details, + ); + } + let (guardian_secret, guardian_body, signed_guardian, guardian_set, threshold_evidence) = + guardian_fixture::fixture(); + catalog.add( + "recovery-authority-plan", + "RecoveryAuthorityPlan", + &recovery_plan, + VectorDetails { + expected_ids: BTreeMap::from([( + "recovery_id", + RecoveryProposal::try_new( + ProtocolVersion::V1, + recovery_plan.clone(), + Extensions::default(), + ) + .unwrap() + .recovery_id() + .unwrap() + .to_string(), + )]), + ..VectorDetails::default() + }, + ); + catalog.add( + "recovery-proposal", + "RecoveryProposal", + &proposal, + VectorDetails { + dependencies: vec!["recovery-authority-plan".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "recovery-begin", + "BeginRecovery", + &begin, + VectorDetails { + dependencies: vec!["recovery-proposal".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "recovery-veto", + "VetoRecovery", + &veto, + VectorDetails::default(), + ); + catalog.add( + "recovery-cancel", + "CancelRecovery", + &cancel, + VectorDetails::default(), + ); + catalog.add( + "recovery-finalize", + "FinalizeRecovery", + &finalize, + VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: recovery_signatures.clone(), + dependencies: vec!["recovery-delay-anchor".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "recovery-delay-anchor", + "RecoveryDelayAnchor", + &delay_anchor, + VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: recovery_signatures, + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "fork-descriptor", + "ForkDescriptor", + &fork, + VectorDetails { + expected_ids: BTreeMap::from([("fork_id", fork.fork_id().unwrap().to_string())]), + ..VectorDetails::default() + }, + ); + catalog.add( + "guardian-approval-body", + "GuardianApprovalBody", + &guardian_body, + VectorDetails { + ..VectorDetails::default() + }, + ); + catalog.add( + "signed-guardian-approval", + "SignedGuardianApproval", + &signed_guardian, + VectorDetails { + dependencies: vec!["guardian-approval-body".to_owned()], + ..signature_details( + "KRIKOS-ID/guardian-approval-signature/v1", + guardian_body.signing_bytes().unwrap(), + &guardian_secret, + *signed_guardian.signature().as_bytes(), + ) + }, + ); + let guardian_bindings = guardian_set + .as_slice() + .iter() + .enumerate() + .map(|(index, approval)| { + let seed = (1_u8..=2) + .find(|seed| { + SigningPublicKey::ed25519( + *SecretKey::from_bytes(&[*seed; 32]).public().as_bytes(), + ) + .unwrap() + == approval.opening().grant().guardian_signing_key() + }) + .expect("guardian approval must resolve to its exact deterministic key"); + let secret = SecretKey::from_bytes(&[seed; 32]); + signature_binding( + &format!("signature-{}", index + 1), + "KRIKOS-ID/guardian-approval-signature/v1", + approval.body().signing_bytes().unwrap(), + &secret, + *approval.signature().as_bytes(), + ) + }) + .collect::>(); + catalog.add( + "guardian-approval-set", + "GuardianApprovalSet", + &guardian_set, + VectorDetails { + dependencies: vec!["signed-guardian-approval".to_owned()], + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: guardian_bindings.clone(), + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "guardian-threshold-evidence", + "RecoveryThresholdEvidence", + &threshold_evidence, + VectorDetails { + dependencies: vec!["guardian-approval-set".to_owned()], + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: guardian_bindings, + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + + catalog.add( + "crypto-migration-begin", + "BeginCryptoMigration", + &migration, + VectorDetails { + expected_ids: BTreeMap::from([("crypto_migration_id", migration_id.to_string())]), + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: migration_signatures, + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "controller-key-binding-proof", + "ControllerKeyBindingProof", + &migration.proofs().as_slice()[0], + VectorDetails { + version_scope: "v1 inherited from exact crypto migration begin", + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: crypto_migration_signature_bindings(&migration), + dependencies: vec!["crypto-migration-begin".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + + let capability = capability_fixture::fixture(); + catalog.add( + "capability-grant", + "CapabilityGrant", + &capability.grant, + VectorDetails { + expected_ids: BTreeMap::from([( + "capability_grant_id", + capability.grant.capability_grant_id().unwrap().to_string(), + )]), + ..VectorDetails::default() + }, + ); + catalog.add( + "capability-root-grant", + "CapabilityGrant", + capability.root.grant(), + VectorDetails { + expected_ids: BTreeMap::from([( + "capability_grant_id", + capability + .root + .grant() + .capability_grant_id() + .unwrap() + .to_string(), + )]), + ..VectorDetails::default() + }, + ); + catalog.add( + "capability-root", + "CapabilityRoot", + &capability.root, + VectorDetails { + dependencies: vec!["capability-root-grant".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "delegation-body", + "DelegationBody", + &capability.body, + VectorDetails { + expected_ids: BTreeMap::from([( + "delegation_id", + capability.body.delegation_id().unwrap().to_string(), + )]), + dependencies: vec!["capability-grant".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "signed-delegation", + "SignedDelegation", + &capability.signed, + VectorDetails { + dependencies: vec!["delegation-body".to_owned()], + ..signature_details( + "KRIKOS-ID/capability-delegation-signature/v1", + capability.body.to_canonical_bytes().unwrap(), + &capability.secret, + *capability.signed.signature().as_bytes(), + ) + }, + ); + catalog.add( + "delegation-chain", + "DelegationChain", + &capability.chain, + VectorDetails { + dependencies: vec!["capability-root".to_owned(), "signed-delegation".to_owned()], + ..signature_details( + "KRIKOS-ID/capability-delegation-signature/v1", + capability.body.to_canonical_bytes().unwrap(), + &capability.secret, + *capability.signed.signature().as_bytes(), + ) + }, + ); + + let (application_secret, _, application_event) = application_fixture::fixture(); + catalog.add( + "application-event-body", + "ApplicationEventBody", + application_event.body(), + VectorDetails { + ..VectorDetails::default() + }, + ); + catalog.add( + "signed-application-event", + "SignedApplicationEvent", + &application_event, + VectorDetails { + expected_ids: BTreeMap::from([( + "application_event_id", + application_event + .application_event_id() + .unwrap() + .to_string(), + )]), + dependencies: vec!["application-event-body".to_owned()], + ..signature_details( + "KRIKOS-ID/application-event-signature/v1", + application_event.body().signing_bytes().unwrap(), + &application_secret, + *application_event.signature().as_bytes(), + ) + }, + ); + + let (wrap_header, wrapped_key, recipient_wraps) = key_wrap_fixture::fixture(); + catalog.add( + "group-key-wrap-header", + "GroupKeyWrapHeader", + &wrap_header, + VectorDetails { + algorithms: vec!["BLAKE3-256", "X25519", "XChaCha20-Poly1305"], + ..VectorDetails::default() + }, + ); + catalog.add( + "wrapped-group-key", + "WrappedGroupKey", + &wrapped_key, + VectorDetails { + algorithms: vec!["BLAKE3-256", "X25519", "XChaCha20-Poly1305"], + expected_ids: BTreeMap::from([( + "group_key_wrap_id", + wrapped_key.group_key_wrap_id().unwrap().to_string(), + )]), + dependencies: vec!["group-key-wrap-header".to_owned()], + tamper_expectation: "key_wrap_authentication_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "recipient-key-wraps", + "RecipientKeyWraps", + &recipient_wraps, + VectorDetails { + algorithms: vec!["BLAKE3-256", "X25519", "XChaCha20-Poly1305"], + dependencies: vec!["wrapped-group-key".to_owned()], + ..VectorDetails::default() + }, + ); + + let (social_secret, social) = social_fixture::fixture(); + catalog.add( + "social-attestation-body", + "SocialAttestationBody", + social.body(), + VectorDetails { + ..VectorDetails::default() + }, + ); + catalog.add( + "signed-social-attestation", + "SignedSocialAttestation", + &social, + VectorDetails { + dependencies: vec!["social-attestation-body".to_owned()], + ..signature_details( + "KRIKOS-ID/social-attestation-signature/v1", + social.body().signing_bytes().unwrap(), + &social_secret, + social.issuer_signature().as_bytes().try_into().unwrap(), + ) + }, + ); + + let (name_secret, name_claim) = name_fixture::fixture(); + catalog.add( + "name-claim-body", + "NameClaimBody", + name_claim.body(), + VectorDetails { + ..VectorDetails::default() + }, + ); + catalog.add( + "signed-name-claim", + "SignedNameClaim", + &name_claim, + VectorDetails { + dependencies: vec!["name-claim-body".to_owned()], + ..signature_details( + "KRIKOS-ID/name-claim-signature/v1", + name_claim.body().signing_bytes().unwrap(), + &name_secret, + name_claim + .subject_signature() + .as_bytes() + .try_into() + .unwrap(), + ) + }, + ); + + let (private_context, private_envelope) = private_metadata_fixture::fixture(); + catalog.add( + "private-artifact-context", + "PrivateArtifactContext", + &private_context, + VectorDetails::default(), + ); + catalog.add( + "private-metadata-envelope", + "PrivateMetadataEnvelope", + &private_envelope, + VectorDetails { + algorithms: vec!["BLAKE3-256", "XChaCha20-Poly1305"], + dependencies: vec!["private-artifact-context".to_owned()], + tamper_expectation: "private_metadata_authentication_rejected", + ..VectorDetails::default() + }, + ); + + let (portable_secret, credential) = portable_fixture::fixture(); + catalog.add( + "portable-credential-body", + "PortableCredentialBody", + credential.body(), + VectorDetails { + ..VectorDetails::default() + }, + ); + catalog.add( + "signed-portable-credential", + "SignedPortableCredential", + &credential, + VectorDetails { + dependencies: vec!["portable-credential-body".to_owned()], + ..signature_details( + "KRIKOS-ID/portable-credential-signature/v1", + credential.body().signing_bytes().unwrap(), + &portable_secret, + credential.issuer_signature().as_bytes().try_into().unwrap(), + ) + }, + ); + + let provider = transparency_fixture::fixture(); + let provider_log_id = typed_id::(0x92); + let provider_store = MemoryProviderStore::new( + provider.provider.clone(), + provider_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let checkpoint_bundle = build_provider_checkpoint_bundle_from_genesis( + &fixture.genesis, + std::slice::from_ref(&fixture.event), + &fixture.checkpoint, + None, + ) + .unwrap(); + let provider_admission = checkpoint_bundle.provider_log_admission(); + let provider_request = ProviderAdmissionRequest::for_admission(&provider_admission).unwrap(); + let provider_permit = authorize_provider_append( + provider_admission, + provider_request, + &AllowProviderAdmission, + ) + .unwrap(); + provider_store + .append( + provider_permit, + Timestamp::from_unix_millis(120), + &InteropProviderSigner(&provider.secret), + ) + .unwrap(); + let provider_generation = provider_store.export_generation().unwrap(); + let provider_audit_store = + MemoryProviderAuditStore::new(provider.provider.clone(), provider_log_id); + let provider_auditor = DurableProviderAuditor::new(provider_audit_store.clone()); + provider_auditor + .observe(provider_generation.latest_head().unwrap().clone(), None) + .unwrap(); + let rollback_body = ProviderHeadBody::new( + provider.provider.id().unwrap(), + provider_log_id, + ProviderKeyVersion::GENESIS, + provider_generation + .latest_head() + .unwrap() + .body() + .tree_size(), + provider_generation + .latest_head() + .unwrap() + .body() + .tree_root(), + Timestamp::from_unix_millis(119), + Extensions::default(), + ) + .unwrap(); + let rollback_head = SignedProviderHead::new( + rollback_body.clone(), + ProtocolSignature::ed25519( + provider + .secret + .sign(&rollback_body.signing_bytes().unwrap()) + .to_bytes(), + ), + ); + assert_eq!( + provider_auditor.observe(rollback_head.clone(), None), + Err(IdentityError::ProviderRollback) + ); + let provider_recovery = ProviderRecoveryExport::new( + provider_generation.clone(), + provider_audit_store.snapshot().unwrap(), + ) + .unwrap(); + let provider_inventory = derive_provider_retention_inventory(&provider_recovery).unwrap(); + let provider_compaction = + verify_provider_compaction(&provider_recovery, &provider_recovery, &provider_inventory) + .unwrap() + .manifest() + .clone(); + let provider_anchor = + OpaqueProviderAnchorCommitment::from_compaction_manifest(&provider_compaction).unwrap(); + let (provider_recovery_manifest, provider_generation_chunks, provider_audit_chunks) = + provider_recovery.interchange_parts().unwrap(); + let provider_generation_manifest = provider_recovery_manifest.generation().clone(); + let provider_audit_manifest = provider_recovery_manifest.audit().clone(); + let provider_component = ProviderExportComponent::CheckpointBundles; + let provider_component_descriptor = provider_generation_manifest + .descriptor(provider_component) + .unwrap() + .clone(); + let provider_generation_chunk = provider_generation_chunks + .into_iter() + .find(|chunk| chunk.component() == Ok(provider_component)) + .expect("populated provider generation has one checkpoint-bundle chunk"); + let provider_audit_chunk = provider_audit_chunks + .into_iter() + .next() + .expect("populated provider audit has one audit chunk"); + let generation_head = provider_generation.latest_head().unwrap().clone(); + let provider_entry = provider_generation.entries()[0].clone(); + let provider_receipt = provider_generation.receipts()[0].clone(); + let provider_receipts = ProviderReceipts::new(vec![provider_receipt.clone()]).unwrap(); + let conflicting_body = ProviderHeadBody::new( + provider.provider.id().unwrap(), + provider_log_id, + ProviderKeyVersion::GENESIS, + generation_head.body().tree_size(), + Digest::new(HashAlgorithm::Blake3_256, [0x99; 32]), + Timestamp::from_unix_millis(121), + Extensions::default(), + ) + .unwrap(); + let conflicting_head = SignedProviderHead::new( + conflicting_body.clone(), + ProtocolSignature::ed25519( + provider + .secret + .sign(&conflicting_body.signing_bytes().unwrap()) + .to_bytes(), + ), + ); + let provider_evidence = ProviderEquivocationEvidence::new( + &provider.provider, + generation_head.clone(), + conflicting_head, + ) + .unwrap(); + catalog.add( + "provider-log-entry", + "ProviderLogEntryBody", + &provider_entry, + VectorDetails { + expected_ids: BTreeMap::from([( + "merkle_leaf_hash", + provider_entry.merkle_leaf_hash().unwrap().to_string(), + )]), + ..VectorDetails::default() + }, + ); + catalog.add( + "provider-head-body", + "ProviderHeadBody", + generation_head.body(), + VectorDetails { + dependencies: vec!["provider-log-entry".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "signed-provider-head", + "SignedProviderHead", + &generation_head, + VectorDetails { + dependencies: vec!["provider-head-body".to_owned()], + ..signature_details( + "KRIKOS-ID/provider-head-signature/v1", + generation_head.body().signing_bytes().unwrap(), + &provider.secret, + *generation_head.signature().as_bytes(), + ) + }, + ); + catalog.add( + "inclusion-receipt", + "InclusionReceipt", + &provider_receipt, + VectorDetails { + dependencies: vec![ + "provider-log-entry".to_owned(), + "signed-provider-head".to_owned(), + ], + ..signature_details( + "KRIKOS-ID/provider-head-signature/v1", + generation_head.body().signing_bytes().unwrap(), + &provider.secret, + *generation_head.signature().as_bytes(), + ) + }, + ); + catalog.add( + "provider-receipts", + "ProviderReceipts", + &provider_receipts, + VectorDetails { + dependencies: vec!["inclusion-receipt".to_owned()], + ..signature_details( + "KRIKOS-ID/provider-head-signature/v1", + generation_head.body().signing_bytes().unwrap(), + &provider.secret, + *generation_head.signature().as_bytes(), + ) + }, + ); + catalog.add( + "provider-equivocation-evidence", + "ProviderEquivocationEvidence", + &provider_evidence, + VectorDetails { + dependencies: vec!["signed-provider-head".to_owned()], + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: [provider_evidence.first(), provider_evidence.second()] + .iter() + .enumerate() + .map(|(index, head)| { + signature_binding( + &format!("signature-{}", index + 1), + "KRIKOS-ID/provider-head-signature/v1", + head.body().signing_bytes().unwrap(), + &provider.secret, + *head.signature().as_bytes(), + ) + }) + .collect(), + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + let event_approval = &fixture.event.approvals().as_slice()[0]; + let checkpoint_approval = &fixture + .checkpoint + .authorization() + .controller_approvals() + .unwrap() + .as_slice()[0]; + let provider_checkpoint_chunk_bindings = vec![ + signature_binding( + "signature-1", + "KRIKOS-ID/controller-approval-signature/v1", + event_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + event_approval.signatures()[0] + .signature() + .as_bytes() + .try_into() + .unwrap(), + ), + signature_binding( + "signature-2", + "KRIKOS-ID/controller-approval-signature/v1", + checkpoint_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + checkpoint_approval.signatures()[0] + .signature() + .as_bytes() + .try_into() + .unwrap(), + ), + ]; + catalog.add( + "provider-export-component", + "ProviderExportComponent", + &provider_component, + VectorDetails { + version_scope: "authoritative-provider-interchange-format-v1", + ..VectorDetails::default() + }, + ); + catalog.add( + "provider-export-component-descriptor", + "ProviderExportComponentDescriptor", + &provider_component_descriptor, + VectorDetails { + version_scope: "authoritative-provider-interchange-format-v1", + dependencies: vec!["provider-export-component".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "provider-generation-export-chunk", + "ProviderGenerationExportChunk", + &provider_generation_chunk, + VectorDetails { + version_scope: "authoritative-provider-interchange-format-v1", + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: provider_checkpoint_chunk_bindings, + dependencies: vec![ + "account-genesis".to_owned(), + "authorized-event".to_owned(), + "checkpoint-direct".to_owned(), + "provider-generation-export-manifest".to_owned(), + ], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + let provider_audit_chunk_bindings = provider_recovery + .audit() + .records() + .iter() + .enumerate() + .map(|(index, record)| { + signature_binding( + &format!("signature-{}", index + 1), + "KRIKOS-ID/provider-head-signature/v1", + record.head().body().signing_bytes().unwrap(), + &provider.secret, + *record.head().signature().as_bytes(), + ) + }) + .collect(); + catalog.add( + "provider-audit-export-chunk", + "ProviderAuditExportChunk", + &provider_audit_chunk, + VectorDetails { + version_scope: "authoritative-provider-interchange-format-v1", + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: provider_audit_chunk_bindings, + dependencies: vec!["provider-audit-export-manifest".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + let provider_generation_manifest_binding = signature_binding( + "signature-1", + "KRIKOS-ID/provider-head-signature/v1", + generation_head.body().signing_bytes().unwrap(), + &provider.secret, + *generation_head.signature().as_bytes(), + ); + catalog.add( + "provider-generation-export-manifest", + "ProviderGenerationExportManifest", + &provider_generation_manifest, + VectorDetails { + version_scope: "authoritative-provider-interchange-format-v1", + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![provider_generation_manifest_binding.clone()], + dependencies: vec![ + "provider-export-component-descriptor".to_owned(), + "signed-provider-head".to_owned(), + ], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + let provider_audit_manifest_binding = signature_binding( + "signature-1", + "KRIKOS-ID/provider-head-signature/v1", + provider_audit_manifest + .latest_head() + .unwrap() + .body() + .signing_bytes() + .unwrap(), + &provider.secret, + *provider_audit_manifest + .latest_head() + .unwrap() + .signature() + .as_bytes(), + ); + catalog.add( + "provider-audit-export-manifest", + "ProviderAuditExportManifest", + &provider_audit_manifest, + VectorDetails { + version_scope: "authoritative-provider-interchange-format-v1", + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![provider_audit_manifest_binding.clone()], + dependencies: vec!["signed-provider-head".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + let provider_recovery_bindings = vec![ + provider_generation_manifest_binding, + signature_binding( + "signature-2", + "KRIKOS-ID/provider-head-signature/v1", + provider_audit_manifest + .latest_head() + .unwrap() + .body() + .signing_bytes() + .unwrap(), + &provider.secret, + *provider_audit_manifest + .latest_head() + .unwrap() + .signature() + .as_bytes(), + ), + ]; + catalog.add( + "provider-recovery-export-manifest", + "ProviderRecoveryExportManifest", + &provider_recovery_manifest, + VectorDetails { + version_scope: "authoritative-provider-interchange-format-v1", + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: provider_recovery_bindings, + dependencies: vec![ + "provider-generation-export-manifest".to_owned(), + "provider-audit-export-manifest".to_owned(), + ], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "provider-compaction-manifest", + "ProviderCompactionManifest", + &provider_compaction, + VectorDetails { + version_scope: "authoritative-provider-compaction-format-v1", + dependencies: vec!["provider-recovery-export-manifest".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "opaque-provider-anchor-commitment", + "OpaqueProviderAnchorCommitment", + &provider_anchor, + VectorDetails { + version_scope: "authoritative-provider-anchor-format-v1", + derivations: vec![provider_anchor_commitment_derivation( + provider_anchor, + &provider_compaction, + )], + dependencies: vec!["provider-compaction-manifest".to_owned()], + ..VectorDetails::default() + }, + ); + + use krikos_identity::merkle::{MerkleSet, MerkleSetKey, MerkleSetLeaf}; + let key = MerkleSetKey::new(7, Digest::new(HashAlgorithm::Blake3_256, [0xd1; 32])).unwrap(); + let leaf = MerkleSetLeaf::new(key, Digest::new(HashAlgorithm::Blake3_256, [0xd2; 32])); + let set = MerkleSet::new(vec![ + leaf, + MerkleSetLeaf::new( + MerkleSetKey::new(7, Digest::new(HashAlgorithm::Blake3_256, [0xd3; 32])).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0xd4; 32]), + ), + MerkleSetLeaf::new( + MerkleSetKey::new(7, Digest::new(HashAlgorithm::Blake3_256, [0xd5; 32])).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0xd6; 32]), + ), + ]) + .unwrap(); + let inclusion = set.inclusion_proof(key).unwrap(); + let consistency = set.consistency_proof(1).unwrap(); + let missing_key = + MerkleSetKey::new(7, Digest::new(HashAlgorithm::Blake3_256, [0xd0; 32])).unwrap(); + let non_membership = set.non_membership_proof(missing_key).unwrap(); + catalog.add( + "merkle-set-key", + "MerkleSetKey", + &missing_key, + VectorDetails { + protocol_version: None, + version_scope: "standalone Merkle structure; version inherited from enclosing v1 object", + ..VectorDetails::default() + }, + ); + catalog.add( + "merkle-set-leaf", + "MerkleSetLeaf", + &leaf, + VectorDetails { + protocol_version: None, + version_scope: "standalone Merkle structure; version inherited from enclosing v1 object", + ..VectorDetails::default() + }, + ); + catalog.add( + "merkle-inclusion-proof", + "MerkleInclusionProof", + &inclusion, + VectorDetails { + protocol_version: None, + version_scope: "standalone Merkle structure; version inherited from enclosing v1 object", + expected_ids: BTreeMap::from([("merkle_root", set.root().unwrap().to_string())]), + derivations: merkle_inclusion_derivations( + &leaf, + &inclusion, + "merkle_leaf_hash", + "merkle_root", + ), + dependencies: vec!["merkle-set-leaf".to_owned()], + tamper_expectation: "merkle_proof_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "merkle-consistency-proof", + "MerkleConsistencyProof", + &consistency, + VectorDetails { + protocol_version: None, + version_scope: "standalone Merkle structure; version inherited from enclosing v1 object", + expected_ids: BTreeMap::from([ + ( + "old_merkle_root", + MerkleSet::new(set.entries()[..1].to_vec()) + .unwrap() + .root() + .unwrap() + .to_string(), + ), + ("new_merkle_root", set.root().unwrap().to_string()), + ]), + derivations: merkle_consistency_derivations(&leaf, &consistency), + dependencies: vec!["merkle-set-leaf".to_owned()], + tamper_expectation: "merkle_proof_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "merkle-non-membership-proof", + "MerkleNonMembershipProof", + &non_membership, + VectorDetails { + protocol_version: None, + version_scope: "standalone Merkle structure; version inherited from enclosing v1 object", + expected_ids: BTreeMap::from([ + ("merkle_root", set.root().unwrap().to_string()), + ( + "missing_key", + hex::encode(missing_key.to_canonical_bytes().unwrap()), + ), + ]), + dependencies: vec!["merkle-set-key".to_owned()], + tamper_expectation: "merkle_proof_rejected", + ..VectorDetails::default() + }, + ); + + let pairing_ticket = + PairingTicket::from_canonical_bytes(&fuzz_seed_payload("seed.txt", 0)).unwrap(); + let pairing_transcript = PairingTranscript::from_canonical_bytes(&fuzz_seed_payload( + "selector-1-pairing-transcript", + 1, + )) + .unwrap(); + let pairing_proof = PairingPossessionProof::from_canonical_bytes(&fuzz_seed_payload( + "selector-2-pairing-proof", + 2, + )) + .unwrap(); + let device_proposal = DeviceAuthorizationProposal::from_canonical_bytes(&fuzz_seed_payload( + "selector-3-device-authorization-proposal", + 3, + )) + .unwrap(); + let presence_challenge = DevicePresenceChallenge::from_canonical_bytes(&fuzz_seed_payload( + "selector-4-presence-challenge", + 4, + )) + .unwrap(); + let presence_proof = + PresenceProof::from_canonical_bytes(&fuzz_seed_payload("selector-5-presence-proof", 5)) + .unwrap(); + let pairing_application_secret = SecretKey::from_bytes(&[10; 32]); + let pairing_application_message = pairing_transcript + .application_possession_signing_bytes() + .unwrap(); + let pairing_endpoint_secret = SecretKey::from_bytes(&[12; 32]); + let pairing_endpoint_message = pairing_transcript + .endpoint_possession_signing_bytes() + .unwrap(); + let transcript_bytes = pairing_transcript.to_canonical_bytes().unwrap(); + let pairing_macs = vec![ + pairing_mac_binding( + "agreement-possession", + "KRIKOS-ID/pairing-agreement-proof-key/v1", + "KRIKOS-ID/pairing-agreement-possession/v1", + PairingMacKeyInputs { + secret_seed: [11; 32], + subject_public_key: pairing_transcript.proposed_device().agreement_key(), + connection_public_key: pairing_transcript.connection_ephemeral_public_key(), + }, + &transcript_bytes, + pairing_proof.agreement_mac(), + ), + pairing_mac_binding( + "pairing-ephemeral-possession", + "KRIKOS-ID/pairing-ephemeral-proof-key/v1", + "KRIKOS-ID/pairing-ephemeral-possession/v1", + PairingMacKeyInputs { + secret_seed: [0x5a; 32], + subject_public_key: pairing_transcript.pairing_ephemeral_public_key(), + connection_public_key: pairing_transcript.connection_ephemeral_public_key(), + }, + &transcript_bytes, + pairing_proof.pairing_ephemeral_mac(), + ), + ]; + catalog.add( + "pairing-ticket", + "PairingTicket", + &pairing_ticket, + VectorDetails { + expected_ids: BTreeMap::from([( + "pairing_ticket_id", + pairing_ticket.ticket_id().unwrap().as_digest().to_string(), + )]), + tamper_expectation: "identifier_or_binding_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "pairing-transcript", + "PairingTranscript", + &pairing_transcript, + VectorDetails { + expected_ids: BTreeMap::from([( + "pairing_transcript_id", + pairing_transcript + .transcript_id() + .unwrap() + .as_digest() + .to_string(), + )]), + dependencies: vec!["pairing-ticket".to_owned()], + tamper_expectation: "identifier_or_binding_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "pairing-possession-proof", + "PairingPossessionProof", + &pairing_proof, + VectorDetails { + expected_ids: BTreeMap::from([( + "pairing_proof_id", + pairing_proof.proof_id().unwrap().as_digest().to_string(), + )]), + dependencies: vec!["pairing-transcript".to_owned()], + algorithms: vec!["BLAKE3-256", "Ed25519", "X25519"], + signature_bindings: vec![ + signature_binding( + "signature-1", + "KRIKOS-ID/pairing-application-possession/v1", + pairing_application_message, + &pairing_application_secret, + *pairing_proof.application_signature().as_bytes(), + ), + signature_binding( + "signature-2", + "KRIKOS-ID/pairing-endpoint-possession/v1", + pairing_endpoint_message, + &pairing_endpoint_secret, + *pairing_proof.endpoint_signature().as_bytes(), + ), + ], + mac_bindings: pairing_macs, + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "pairing-confirmation-context", + "PairingConfirmationContext", + &device_proposal.confirmation(), + VectorDetails { + dependencies: vec!["pairing-transcript".to_owned()], + tamper_expectation: "identifier_or_binding_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "device-authorization-proposal", + "DeviceAuthorizationProposal", + &device_proposal, + VectorDetails { + expected_ids: BTreeMap::from([( + "device_authorization_proposal_id", + device_proposal + .proposal_id() + .unwrap() + .as_digest() + .to_string(), + )]), + dependencies: vec![ + "pairing-ticket".to_owned(), + "pairing-transcript".to_owned(), + "pairing-possession-proof".to_owned(), + "pairing-confirmation-context".to_owned(), + ], + tamper_expectation: "identifier_or_binding_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "presence-challenge", + "DevicePresenceChallenge", + &presence_challenge, + VectorDetails { + ..VectorDetails::default() + }, + ); + catalog.add( + "presence-proof", + "PresenceProof", + &presence_proof, + VectorDetails { + expected_ids: BTreeMap::from([( + "presence_proof_id", + presence_proof.proof_id().unwrap().as_digest().to_string(), + )]), + dependencies: vec!["presence-challenge".to_owned()], + ..signature_details( + "KRIKOS-ID/device-presence-signature/v1", + presence_challenge.signing_bytes().unwrap(), + &pairing_application_secret, + *presence_proof.signature().as_bytes(), + ) + }, + ); + + let sync_account_id = fixture.genesis.account_id().unwrap(); + let sync_head = fixture.event.event_id().unwrap(); + let sync_cursor = SyncCursor::issue( + &CursorKey::new(INTEROP_SYNC_CURSOR_KEY).unwrap(), + sync_account_id, + vec![sync_head], + 1, + 512, + ) + .unwrap(); + let sync_request = SyncRequest::new( + sync_account_id, + vec![sync_head], + Some(sync_cursor.clone()), + 64, + 64 * 1024, + ) + .unwrap(); + let sync_frame = SyncFrame::new( + sync_account_id, + vec![sync_head], + vec![fixture.event.clone()], + Some(sync_cursor.clone()), + ) + .unwrap(); + let sync_response_frame = SyncResponse::frame(sync_frame.clone()); + let sync_response_complete = SyncResponse::complete(sync_account_id, vec![sync_head]).unwrap(); + let endpoint_authorization = EndpointAuthorizationRequest::new( + sync_account_id, + fixture.checkpoint.checkpoint_id().unwrap(), + typed_id::(0x93), + ); + let authorized_sync = + AuthorizedSyncRequest::new(endpoint_authorization, sync_request.clone()).unwrap(); + let proposal_authorization = EndpointAuthorizationRequest::new( + device_proposal.account_id(), + typed_id::(0x94), + device_proposal.proposed_device_id(), + ); + let authorized_proposal = + AuthorizedProposalRequest::new(proposal_authorization, device_proposal.clone()).unwrap(); + let authorized_checkpoint = + AuthorizedCheckpointRequest::new(endpoint_authorization, fixture.checkpoint.clone()) + .unwrap(); + let identity_ack = IdentityProtocolAck::for_canonical_request( + IdentityProtocolKind::Sync, + &sync_request.to_canonical_bytes().unwrap(), + IdentityServiceOutcome::Accepted, + ); + let identity_reply_ack = IdentityProtocolReply::acknowledgement(identity_ack.clone()); + let identity_reply_sync = IdentityProtocolReply::synchronization(sync_response_frame.clone()); + let identity_ack_derivation = network_request_commitment_derivation( + &identity_ack, + &sync_request.to_canonical_bytes().unwrap(), + ); + let event_binding = || { + signature_binding( + "signature-1", + "KRIKOS-ID/controller-approval-signature/v1", + event_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + event_approval.signatures()[0] + .signature() + .as_bytes() + .try_into() + .unwrap(), + ) + }; + let checkpoint_binding = || { + signature_binding( + "signature-1", + "KRIKOS-ID/controller-approval-signature/v1", + checkpoint_approval.body().to_canonical_bytes().unwrap(), + &fixture.signer, + checkpoint_approval.signatures()[0] + .signature() + .as_bytes() + .try_into() + .unwrap(), + ) + }; + let cursor_mac = || sync_cursor_mac_binding("cursor-authenticator-1", &sync_cursor); + + catalog.add( + "sync-cursor", + "SyncCursor", + &sync_cursor, + VectorDetails { + algorithms: vec!["BLAKE3-256"], + mac_bindings: vec![cursor_mac()], + tamper_expectation: "cursor_authentication_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "sync-request", + "SyncRequest", + &sync_request, + VectorDetails { + algorithms: vec!["BLAKE3-256"], + mac_bindings: vec![cursor_mac()], + dependencies: vec!["sync-cursor".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "sync-frame", + "SyncFrame", + &sync_frame, + VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![event_binding()], + mac_bindings: vec![cursor_mac()], + dependencies: vec!["authorized-event".to_owned(), "sync-cursor".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "sync-response-frame", + "SyncResponse", + &sync_response_frame, + VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![event_binding()], + mac_bindings: vec![cursor_mac()], + dependencies: vec!["sync-frame".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "sync-response-complete", + "SyncResponse", + &sync_response_complete, + VectorDetails::default(), + ); + catalog.add( + "endpoint-authorization-request", + "EndpointAuthorizationRequest", + &endpoint_authorization, + VectorDetails::default(), + ); + catalog.add( + "proposal-endpoint-authorization-request", + "EndpointAuthorizationRequest", + &proposal_authorization, + VectorDetails::default(), + ); + catalog.add( + "authorized-sync-request", + "AuthorizedSyncRequest", + &authorized_sync, + VectorDetails { + version_scope: "v1 inherited from exact nested authorization and sync request", + algorithms: vec!["BLAKE3-256"], + mac_bindings: vec![cursor_mac()], + dependencies: vec![ + "endpoint-authorization-request".to_owned(), + "sync-request".to_owned(), + ], + ..VectorDetails::default() + }, + ); + catalog.add( + "authorized-proposal-request", + "AuthorizedProposalRequest", + &authorized_proposal, + VectorDetails { + version_scope: "v1 inherited from exact nested authorization and proposal", + dependencies: vec![ + "proposal-endpoint-authorization-request".to_owned(), + "device-authorization-proposal".to_owned(), + ], + ..VectorDetails::default() + }, + ); + catalog.add( + "authorized-checkpoint-request", + "AuthorizedCheckpointRequest", + &authorized_checkpoint, + VectorDetails { + version_scope: "v1 inherited from exact nested authorization and checkpoint", + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![checkpoint_binding()], + dependencies: vec![ + "endpoint-authorization-request".to_owned(), + "checkpoint-direct".to_owned(), + ], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + catalog.add( + "identity-protocol-ack", + "IdentityProtocolAck", + &identity_ack, + VectorDetails { + derivations: vec![identity_ack_derivation.clone()], + dependencies: vec!["sync-request".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "identity-protocol-reply-ack", + "IdentityProtocolReply", + &identity_reply_ack, + VectorDetails { + derivations: vec![identity_ack_derivation], + dependencies: vec!["identity-protocol-ack".to_owned()], + ..VectorDetails::default() + }, + ); + catalog.add( + "identity-protocol-reply-sync", + "IdentityProtocolReply", + &identity_reply_sync, + VectorDetails { + algorithms: vec!["BLAKE3-256", "Ed25519"], + signature_bindings: vec![event_binding()], + mac_bindings: vec![cursor_mac()], + dependencies: vec!["sync-response-frame".to_owned()], + tamper_expectation: "signature_invalid_or_decode_rejected", + ..VectorDetails::default() + }, + ); + + catalog + .vectors + .sort_by(|left, right| left.name.cmp(&right.name)); + let deterministic_key = |name, seed| { + let secret = SecretKey::from_bytes(&[seed; 32]); + KeyMetadata { + name, + algorithm: "Ed25519", + test_only_secret_seed_hex: hex::encode([seed; 32]), + public_key_hex: hex::encode(secret.public().as_bytes()), + } + }; + let deterministic_agreement_key = |name, seed| { + let secret = StaticSecret::from([seed; 32]); + KeyMetadata { + name, + algorithm: "X25519", + test_only_secret_seed_hex: hex::encode([seed; 32]), + public_key_hex: hex::encode(X25519PublicKey::from(&secret).as_bytes()), + } + }; + let required_inventory = catalog + .vectors + .iter() + .map(|vector| vector.name.clone()) + .collect(); + let manifest = Manifest { + format: "KRIKOS-ID interoperability vectors", + format_version: 2, + binding_schema_version: 1, + derivation_schema_version: 1, + canonical_profile: "Postcard 1.1.3 / KRIKOS-ID v1", + algorithms: BTreeMap::from([ + ("hash", "BLAKE3-256 (code 1)"), + ("signature", "Ed25519 (code 1)"), + ("agreement", "X25519 (code 1)"), + ("kdf", "BLAKE3 derive-key (code 1)"), + ("aead", "XChaCha20-Poly1305 (code 1)"), + ]), + deterministic_keys: vec![ + deterministic_key("guardian-1", 0x01), + deterministic_key("guardian-2", 0x02), + deterministic_key("pairing-presence-application", 0x0a), + deterministic_key("pairing-endpoint", 0x0c), + deterministic_key("account-controller-and-social-issuer", 0x11), + deterministic_key("application-device", 0x31), + deterministic_key("capability-delegator", 0x35), + deterministic_key("name-and-portable-credential-issuer", 0x41), + deterministic_key("transparency-provider", 0x71), + deterministic_key("migration-successor-controller", 0x91), + deterministic_agreement_key("pairing-proposed-agreement", 0x0b), + deterministic_agreement_key("pairing-ticket-ephemeral", 0x5a), + ], + private_wire_exclusions: vec![ + Exclusion { + wire_type: "GuardianGrant", + reason: "private guardian identity and weight witness; no standalone public CanonicalWire implementation", + covered_by: "SignedGuardianApproval private nested encoding", + }, + Exclusion { + wire_type: "GuardianGrantOpening", + reason: "private blinding and membership witness; no standalone public CanonicalWire implementation", + covered_by: "SignedGuardianApproval private nested encoding", + }, + ], + transient_wire_dispositions: vec![Exclusion { + wire_type: "PairingConfirmation", + reason: "public transient ceremony message intentionally has no CanonicalWire implementation and is consumed before retained proposal construction", + covered_by: "pairing ceremony state-machine tests plus PairingConfirmationContext and DeviceAuthorizationProposal vectors", + }], + required_inventory, + vectors: catalog.vectors, + }; + let json = serde_json::to_vec_pretty(&manifest).unwrap(); + fs::write(directory.join("manifest.json"), json).unwrap(); +} diff --git a/protocols/krikos-identity/examples/provider_auditor.rs b/protocols/krikos-identity/examples/provider_auditor.rs new file mode 100644 index 00000000000..051ab86bb68 --- /dev/null +++ b/protocols/krikos-identity/examples/provider_auditor.rs @@ -0,0 +1,76 @@ +//! Compare two provider heads from bounded canonical files and emit equivocation evidence. + +use std::{env, fs, io::Read, path::Path}; + +use krikos_identity::{ + CanonicalWire, IdentityError, ProviderDescriptor, ProviderHeadAuditor, SignedProviderHead, + limits::MAX_ENCODED_OBJECT_BYTES, merkle::MerkleConsistencyProof, +}; + +fn read_wire(path: &Path) -> Result> { + let maximum_with_sentinel = u64::try_from(MAX_ENCODED_OBJECT_BYTES) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider auditor input bound", + })? + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider auditor input bound", + })?; + let mut bytes = Vec::with_capacity(MAX_ENCODED_OBJECT_BYTES.saturating_add(1)); + fs::File::open(path)? + .take(maximum_with_sentinel) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_ENCODED_OBJECT_BYTES { + return Err(IdentityError::LimitExceeded { + resource: "provider auditor input bytes", + actual: bytes.len(), + maximum: MAX_ENCODED_OBJECT_BYTES, + } + .into()); + } + Ok(T::from_canonical_bytes(&bytes)?) +} + +fn run() -> Result<(), Box> { + let arguments = env::args_os().skip(1).collect::>(); + if !(arguments.len() == 3 || arguments.len() == 4 || arguments.len() == 5) { + return Err( + "usage: provider_auditor PROVIDER HEAD_A HEAD_B [CONSISTENCY_PROOF] [EVIDENCE_OUT]" + .into(), + ); + } + let provider = read_wire::(Path::new(&arguments[0]))?; + let first = read_wire::(Path::new(&arguments[1]))?; + let second = read_wire::(Path::new(&arguments[2]))?; + let proof = arguments + .get(3) + .map(|path| read_wire::(Path::new(path))) + .transpose()?; + let mut auditor = ProviderHeadAuditor::new(provider.clone(), first.body().log_id()); + let first_disposition = auditor.observe(first, None)?; + println!("first={first_disposition:?}"); + match auditor.observe(second, proof.as_ref()) { + Ok(disposition) => { + println!("second={disposition:?}"); + Ok(()) + } + Err(IdentityError::ProviderEquivocation) => { + let evidence = auditor + .equivocation_evidence() + .ok_or(IdentityError::StorageCorruption)?; + evidence.verify(&provider)?; + if let Some(output) = arguments.get(4) { + fs::write(output, evidence.to_canonical_bytes()?)?; + } + Err(IdentityError::ProviderEquivocation.into()) + } + Err(error) => Err(error.into()), + } +} + +fn main() { + if let Err(error) = run() { + eprintln!("provider audit failed: {error}"); + std::process::exit(1); + } +} diff --git a/protocols/krikos-identity/release-gate.toml b/protocols/krikos-identity/release-gate.toml new file mode 100644 index 00000000000..1e8da295ecc --- /dev/null +++ b/protocols/krikos-identity/release-gate.toml @@ -0,0 +1,31 @@ +# Blocks only krikos-identity from a stable registry release. The four-package +# framework gate remains separate and unchanged. +# +# Evidence requirements, decision authority, and the coordinated opening +# procedure are documented in docs/release-gate.md. CI currently invokes +# scripts/check-identity-release-gate.py --expect-closed. +schema_version = 1 +status = "blocked" + +[package] +name = "krikos-identity" +path = "protocols/krikos-identity" + +[approvals] +third_party_security_audit = false +independently_maintained_interoperability = false +production_provider_diversity = false +protocol_governance = false +public_api_semver_baseline = false +persistent_schema_support = false + +# Each approval needs at least one non-empty, reviewable evidence reference +# before it can become true. References may be repository paths or immutable +# external report identifiers. +[evidence] +third_party_security_audit = [] +independently_maintained_interoperability = [] +production_provider_diversity = [] +protocol_governance = [] +public_api_semver_baseline = [] +persistent_schema_support = [] diff --git a/protocols/krikos-identity/src/application.rs b/protocols/krikos-identity/src/application.rs new file mode 100644 index 00000000000..225a81a3b4d --- /dev/null +++ b/protocols/krikos-identity/src/application.rs @@ -0,0 +1,376 @@ +//! Bounded signed application-event envelopes. + +use krikos_base::{PublicKey, Signature}; +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + AccountId, ApplicationEventId, ApplicationId, AuthorizationContext, CheckpointId, + DeviceAuthorization, DeviceId, Epoch, Extensions, IdentityError, ProtocolSignature, + ProtocolVersion, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_APPLICATION_EVENT_BYTES, MAX_APPLICATION_PAYLOAD_BYTES}, + schema::BoundedBytes, +}; + +const APPLICATION_EVENT_SIGNATURE_DOMAIN: &[u8] = b"KRIKOS-ID/application-event-signature/v1"; + +/// Checked device-local application-event counter. +/// +/// This counter orders events emitted by one device for one application. It deliberately makes no +/// claim about global ordering across devices. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ApplicationEventCounter(u64); + +impl ApplicationEventCounter { + /// Initial device-local counter. + pub const GENESIS: Self = Self(0); + + /// Construct from the exact wire value. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Exact counter value. + pub const fn get(self) -> u64 { + self.0 + } + + /// Advance exactly once, rejecting exhaustion. + pub fn checked_next(self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "application event counter", + }) + } +} + +impl CanonicalCodec for ApplicationEventCounter { + const RESOURCE: &'static str = "application event counter bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Canonical application payload and exact account authorization context signed by one device. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ApplicationEventBody { + protocol_version: ProtocolVersion, + account_id: AccountId, + application_id: ApplicationId, + device_id: DeviceId, + account_epoch: Epoch, + checkpoint_id: CheckpointId, + local_counter: ApplicationEventCounter, + payload: BoundedBytes, + extensions: Extensions, +} + +impl ApplicationEventBody { + /// Construct a bounded v1 application event body. + #[allow(clippy::too_many_arguments)] + pub fn new( + account_id: AccountId, + application_id: ApplicationId, + device_id: DeviceId, + account_epoch: Epoch, + checkpoint_id: CheckpointId, + local_counter: ApplicationEventCounter, + payload: Vec, + extensions: Extensions, + ) -> Result { + let payload = BoundedBytes::new("application event payload bytes", payload)?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + account_id, + application_id, + device_id, + account_epoch, + checkpoint_id, + local_counter, + payload, + extensions, + }) + } + + /// Account whose checkpoint supplies authorization state. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Application namespace interpreting the opaque payload. + pub const fn application_id(&self) -> ApplicationId { + self.application_id + } + + /// Device signing the event. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Account epoch at the referenced authorization checkpoint. + pub const fn account_epoch(&self) -> Epoch { + self.account_epoch + } + + /// Exact account checkpoint against which authorization is evaluated. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Device-local, application-local sequence counter. + pub const fn local_counter(&self) -> ApplicationEventCounter { + self.local_counter + } + + /// Opaque bounded application payload. + pub fn payload(&self) -> &[u8] { + self.payload.as_slice() + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Build the exact domain-separated byte string signed by the device application key. + pub fn signing_bytes(&self) -> Result, IdentityError> { + let canonical_body = encode_wire(self)?; + let capacity = APPLICATION_EVENT_SIGNATURE_DOMAIN + .len() + .checked_add(1) + .and_then(|length| length.checked_add(canonical_body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "application event signing message bytes", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(APPLICATION_EVENT_SIGNATURE_DOMAIN); + message.push(0); + message.extend_from_slice(&canonical_body); + Ok(message) + } +} + +impl<'de> Deserialize<'de> for ApplicationEventBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + application_id: ApplicationId, + device_id: DeviceId, + account_epoch: Epoch, + checkpoint_id: CheckpointId, + local_counter: ApplicationEventCounter, + payload: BoundedBytes, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::new( + wire.account_id, + wire.application_id, + wire.device_id, + wire.account_epoch, + wire.checkpoint_id, + wire.local_counter, + wire.payload.into_vec(), + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ApplicationEventBody { + const RESOURCE: &'static str = "application event body bytes"; + const MAX_ENCODED_BYTES: usize = MAX_APPLICATION_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Complete application event with one exact device-protocol signature. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedApplicationEvent { + body: ApplicationEventBody, + signature: ProtocolSignature, +} + +/// Lifecycle result supplied by a trusted account projection for application verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ApplicationDeviceStatus { + /// No authorization for the named device exists at the supplied context. + Unknown, + /// The device may exercise capabilities installed at the supplied context. + Active, + /// The device is temporarily unable to exercise application authority. + Suspended, + /// The device is permanently unable to exercise application authority. + Revoked, +} + +/// Exact, read-only authorization facts used to verify an application envelope. +/// +/// Implementations represent an already authenticated account checkpoint. This interface performs +/// no network or wall-clock access and deliberately does not claim that the checkpoint is globally +/// fresh. A caller that requires freshness must establish it before constructing the view. +pub trait ApplicationAuthorizationView { + /// Exact account, epoch, and checkpoint represented by this view. + fn authorization_context(&self) -> AuthorizationContext; + + /// Lifecycle of the named device at the exact authorization context. + fn device_status(&self, device_id: DeviceId) -> ApplicationDeviceStatus; + + /// Device authorization installed at the exact context, if one exists. + fn device_authorization(&self, device_id: DeviceId) -> Option<&DeviceAuthorization>; +} + +/// Verify an application event under its exact device key and authorization context. +/// +/// The returned identifier commits to the complete signed envelope. This check establishes local +/// cryptographic authenticity and known-checkpoint authorization only; it does not establish +/// global event order, reachability, presence, or checkpoint freshness. +pub fn verify_application_event( + event: &SignedApplicationEvent, + view: &impl ApplicationAuthorizationView, +) -> Result { + let body = event.body(); + let context = view.authorization_context(); + if body.account_id() != context.account_id() { + return Err(IdentityError::AccountMismatch); + } + if body.account_epoch() != context.epoch() { + return Err(IdentityError::InvalidEpoch); + } + if body.checkpoint_id() != context.checkpoint_id() { + return Err(IdentityError::InvalidRelationship { + resource: "application event authorization checkpoint", + }); + } + + match view.device_status(body.device_id()) { + ApplicationDeviceStatus::Unknown => return Err(IdentityError::DeviceNotAuthorized), + ApplicationDeviceStatus::Active => {} + ApplicationDeviceStatus::Suspended => return Err(IdentityError::DeviceSuspended), + ApplicationDeviceStatus::Revoked => return Err(IdentityError::DeviceRevoked), + } + let authorization = view + .device_authorization(body.device_id()) + .ok_or(IdentityError::DeviceNotAuthorized)?; + if authorization.authorization_epoch() > context.epoch() { + return Err(IdentityError::InvalidEpoch); + } + event.validate_authorization(authorization)?; + + let signing_key = authorization.descriptor().application_signing_key(); + let public_key = PublicKey::from_bytes(signing_key.as_bytes()) + .map_err(|_| IdentityError::InvalidSignature)?; + let signature = Signature::try_from(event.signature().as_bytes().as_slice()) + .map_err(|_| IdentityError::InvalidSignature)?; + public_key + .verify(&body.signing_bytes()?, &signature) + .map_err(|_| IdentityError::InvalidSignature)?; + event.application_event_id() +} + +impl SignedApplicationEvent { + /// Construct a complete event and enforce the one-mebibyte envelope limit. + pub fn new( + body: ApplicationEventBody, + signature: ProtocolSignature, + ) -> Result { + let event = Self { body, signature }; + let encoded_len = encode_wire(&event)?.len(); + if encoded_len > MAX_APPLICATION_EVENT_BYTES { + return Err(IdentityError::limit( + "signed application event bytes", + encoded_len, + MAX_APPLICATION_EVENT_BYTES, + )); + } + Ok(event) + } + + /// Exact signed body. + pub const fn body(&self) -> &ApplicationEventBody { + &self.body + } + + /// Exact fixed-profile signature. Cryptographic verification is performed by Task 4. + pub const fn signature(&self) -> ProtocolSignature { + self.signature + } + + /// Validate the state-dependent device identifier and authorization-epoch relationship. + pub fn validate_authorization( + &self, + authorization: &DeviceAuthorization, + ) -> Result<(), IdentityError> { + if self.body.device_id != authorization.device_id() { + return Err(IdentityError::InvalidRelationship { + resource: "application event signer device", + }); + } + if self.body.account_epoch < authorization.authorization_epoch() { + return Err(IdentityError::InvalidRelationship { + resource: "application event authorization epoch", + }); + } + Ok(()) + } + + /// Derive the identifier of the complete canonical signed envelope. + pub fn application_event_id(&self) -> Result { + ApplicationEventId::derive(self) + } +} + +impl<'de> Deserialize<'de> for SignedApplicationEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + body: ApplicationEventBody, + signature: ProtocolSignature, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.body, wire.signature).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for SignedApplicationEvent { + const RESOURCE: &'static str = "signed application event bytes"; + const MAX_ENCODED_BYTES: usize = MAX_APPLICATION_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} diff --git a/protocols/krikos-identity/src/audit.rs b/protocols/krikos-identity/src/audit.rs new file mode 100644 index 00000000000..b4cab5fc9bc --- /dev/null +++ b/protocols/krikos-identity/src/audit.rs @@ -0,0 +1,1123 @@ +//! Durable, generation-scoped provider-head auditing. + +#[cfg(test)] +use std::cell::Cell; +use std::sync::{Arc, Mutex, MutexGuard}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + Digest, IdentityError, ProviderDescriptor, ProviderEquivocationEvidence, + ProviderHeadAuditDisposition, ProviderHeadAuditor, ProviderLogId, SignedProviderHead, + limits::MAX_RETRIES, merkle::MerkleConsistencyProof, +}; +#[cfg(feature = "provider-store")] +use crate::{ + codec::{decode_wire, encode_wire}, + schema::BoundedVec, +}; + +#[cfg(feature = "provider-store")] +mod redb; + +#[cfg(feature = "provider-store")] +pub use redb::RedbProviderAuditStore; + +pub(crate) const MAX_PROVIDER_AUDIT_RECORDS: usize = 65_536; +#[cfg(feature = "provider-store")] +const MAX_STORED_PROVIDER_AUDIT_BYTES: usize = 256 * 1024 * 1024; +const PROVIDER_AUDIT_ARTIFACT_COMMITMENT_DOMAIN: &[u8] = b"KRIKOS-ID/provider-audit-artifact/v1"; +const PROVIDER_AUDIT_SNAPSHOT_COMMITMENT_DOMAIN: &[u8] = b"KRIKOS-ID/provider-audit-snapshot/v1"; + +#[cfg(test)] +thread_local! { + static PROVIDER_AUDIT_VALIDATION_COUNT: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_provider_audit_validation_count() { + PROVIDER_AUDIT_VALIDATION_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn provider_audit_validation_count() -> usize { + PROVIDER_AUDIT_VALIDATION_COUNT.with(Cell::get) +} + +fn record_provider_audit_validation() { + #[cfg(test)] + PROVIDER_AUDIT_VALIDATION_COUNT.with(|count| count.set(count.get().saturating_add(1))); +} + +#[cfg(feature = "provider-store")] +pub(crate) fn encode_provider_audit_snapshot( + snapshot: &ProviderAuditSnapshot, +) -> Result, IdentityError> { + let bytes = encode_wire(&ProviderAuditSnapshotStorageWire::from_snapshot(snapshot)?)?; + if bytes.len() > MAX_STORED_PROVIDER_AUDIT_BYTES { + return Err(IdentityError::limit( + "stored provider audit snapshot bytes", + bytes.len(), + MAX_STORED_PROVIDER_AUDIT_BYTES, + )); + } + Ok(bytes) +} + +#[cfg(feature = "provider-store")] +pub(crate) fn decode_provider_audit_snapshot( + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_STORED_PROVIDER_AUDIT_BYTES { + return Err(IdentityError::limit( + "stored provider audit snapshot bytes", + bytes.len(), + MAX_STORED_PROVIDER_AUDIT_BYTES, + )); + } + decode_wire::(bytes)?.into_snapshot() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ProviderAuditRecordWire { + sequence: u64, + head: SignedProviderHead, + consistency_proof: Option, + status_code: u16, +} + +#[cfg(feature = "provider-store")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderAuditSnapshotStorageWire { + format_version: u16, + revision: u64, + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, + records: BoundedVec, +} + +/// Authenticated outcome durably retained for one provider-head observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderAuditStatus { + /// The head was accepted by the append-only single-generation auditor. + Accepted(ProviderHeadAuditDisposition), + /// The authenticated head moved backwards in size or provider observation time. + Rollback, + /// The authenticated head conflicts at the same size with a retained root. + Equivocation, +} + +/// Authenticated non-leaf attack class retained independently of provider log entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProviderAuditArtifactKind { + /// A signed head moved backwards in tree size or provider observation time. + Rollback, + /// Two signed heads committed different roots for one exact tree size. + Equivocation, +} + +impl ProviderAuditArtifactKind { + pub(crate) const fn code(self) -> u16 { + match self { + Self::Rollback => 1, + Self::Equivocation => 2, + } + } + + #[cfg(feature = "provider-store")] + pub(crate) fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::Rollback), + 2 => Ok(Self::Equivocation), + _ => Err(IdentityError::StorageCorruption), + } + } +} + +/// One bounded, independently re-verifiable rollback or equivocation artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAuditArtifact { + sequence: u64, + kind: ProviderAuditArtifactKind, + accepted_head: SignedProviderHead, + observed_head: SignedProviderHead, +} + +#[derive(Serialize)] +struct ProviderAuditArtifactCommitmentWire<'a> { + format_version: u16, + sequence: u64, + kind_code: u16, + accepted_head: &'a SignedProviderHead, + observed_head: &'a SignedProviderHead, +} + +impl ProviderAuditArtifact { + pub(crate) fn new( + sequence: u64, + kind: ProviderAuditArtifactKind, + accepted_head: SignedProviderHead, + observed_head: SignedProviderHead, + ) -> Result { + if sequence == 0 + || accepted_head.body().provider_id() != observed_head.body().provider_id() + || accepted_head.body().log_id() != observed_head.body().log_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit artifact generation", + }); + } + let is_equivocation = accepted_head.body().tree_size() == observed_head.body().tree_size() + && accepted_head.body().tree_root() != observed_head.body().tree_root(); + let is_rollback = !is_equivocation + && (observed_head.body().tree_size() < accepted_head.body().tree_size() + || observed_head.body().observed_at() < accepted_head.body().observed_at()); + if !matches!( + (kind, is_rollback, is_equivocation), + (ProviderAuditArtifactKind::Rollback, true, false) + | (ProviderAuditArtifactKind::Equivocation, false, true) + ) { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit artifact classification", + }); + } + Ok(Self { + sequence, + kind, + accepted_head, + observed_head, + }) + } + + /// One-based sequence of the corresponding durable audit record. + pub const fn sequence(&self) -> u64 { + self.sequence + } + + /// Stable attack classification. + pub const fn kind(&self) -> ProviderAuditArtifactKind { + self.kind + } + + /// Last accepted head against which the attack was authenticated. + pub const fn accepted_head(&self) -> &SignedProviderHead { + &self.accepted_head + } + + /// Signed head that proved rollback or equivocation. + pub const fn observed_head(&self) -> &SignedProviderHead { + &self.observed_head + } + + /// Domain-separated exact artifact commitment used by retention manifests. + pub fn commitment(&self) -> Result { + crate::provider::provider_commitment( + PROVIDER_AUDIT_ARTIFACT_COMMITMENT_DOMAIN, + &ProviderAuditArtifactCommitmentWire { + format_version: 1, + sequence: self.sequence, + kind_code: self.kind.code(), + accepted_head: &self.accepted_head, + observed_head: &self.observed_head, + }, + ) + } + + pub(crate) fn verify( + &self, + provider: &ProviderDescriptor, + log_id: ProviderLogId, + ) -> Result<(), IdentityError> { + self.accepted_head.verify(provider)?; + self.observed_head.verify(provider)?; + if self.accepted_head.body().log_id() != log_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit artifact log generation", + }); + } + Self::new( + self.sequence, + self.kind, + self.accepted_head.clone(), + self.observed_head.clone(), + ) + .map(|_| ()) + } +} + +/// One append-only durable provider audit record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAuditRecord { + sequence: u64, + head: SignedProviderHead, + consistency_proof: Option, + status: ProviderAuditStatus, +} + +impl ProviderAuditRecord { + /// Monotonic one-based journal sequence. + pub const fn sequence(&self) -> u64 { + self.sequence + } + + /// Exact authenticated head supplied to the auditor. + pub const fn head(&self) -> &SignedProviderHead { + &self.head + } + + /// Exact consistency evidence supplied with this observation, when required. + pub const fn consistency_proof(&self) -> Option<&MerkleConsistencyProof> { + self.consistency_proof.as_ref() + } + + /// Verified audit outcome retained for this head. + pub const fn status(&self) -> ProviderAuditStatus { + self.status + } +} + +/// Complete durable state for one explicit provider-log generation auditor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAuditSnapshot { + revision: u64, + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, + records: Vec, +} + +#[derive(Serialize)] +struct ProviderAuditRecordCommitmentWire<'a> { + sequence: u64, + head: &'a SignedProviderHead, + consistency_proof: Option<&'a MerkleConsistencyProof>, + status_code: u16, +} + +#[derive(Serialize)] +struct ProviderAuditSnapshotCommitmentWire<'a> { + format_version: u16, + revision: u64, + provider: &'a ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option<&'a SignedProviderHead>, + equivocation: Option<&'a ProviderEquivocationEvidence>, + records: Vec>, +} + +impl ProviderAuditSnapshot { + /// Monotonic journal revision used for compare-and-swap persistence. + pub const fn revision(&self) -> u64 { + self.revision + } + + /// Provider descriptor authenticating every retained head. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Explicit log generation; this journal never rolls over implicitly. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Latest accepted authenticated head. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } + + /// First retained same-size/different-root evidence, after which auditing fails closed. + pub const fn equivocation_evidence(&self) -> Option<&ProviderEquivocationEvidence> { + self.equivocation.as_ref() + } + + /// Append-only authenticated audit records. + pub fn records(&self) -> &[ProviderAuditRecord] { + &self.records + } + + /// Sorted authenticated rollback/equivocation artifacts derived from the full journal. + pub fn artifacts(&self) -> Result, IdentityError> { + self.validate()?; + self.artifacts_validated() + } + + pub(crate) fn artifacts_validated(&self) -> Result, IdentityError> { + let mut latest = None::; + let mut artifacts = Vec::new(); + for record in &self.records { + match record.status { + ProviderAuditStatus::Accepted(_) => latest = Some(record.head.clone()), + ProviderAuditStatus::Rollback => { + let accepted = latest.clone().ok_or(IdentityError::StorageCorruption)?; + artifacts.push(ProviderAuditArtifact::new( + record.sequence, + ProviderAuditArtifactKind::Rollback, + accepted, + record.head.clone(), + )?); + } + ProviderAuditStatus::Equivocation => { + let accepted = latest.clone().ok_or(IdentityError::StorageCorruption)?; + artifacts.push(ProviderAuditArtifact::new( + record.sequence, + ProviderAuditArtifactKind::Equivocation, + accepted, + record.head.clone(), + )?); + } + } + } + artifacts.sort_unstable_by_key(|artifact| (artifact.sequence, artifact.kind.code())); + if artifacts + .windows(2) + .any(|pair| pair[0].sequence == pair[1].sequence && pair[0].kind == pair[1].kind) + { + return Err(IdentityError::StorageCorruption); + } + Ok(artifacts) + } + + pub(crate) fn validate(&self) -> Result<(), IdentityError> { + self.validate_inner(true) + } + + #[cfg(feature = "provider-store")] + pub(crate) fn validate_cached(&self) -> Result<(), IdentityError> { + self.validate_inner(false) + } + + fn validate_inner(&self, validate_portable_bytes: bool) -> Result<(), IdentityError> { + record_provider_audit_validation(); + if self.records.len() > MAX_PROVIDER_AUDIT_RECORDS + || self.revision + != u64::try_from(self.records.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit journal revision", + } + })? + { + return Err(IdentityError::StorageCorruption); + } + let mut auditor = ProviderHeadAuditor::new(self.provider.clone(), self.log_id); + for (index, record) in self.records.iter().enumerate() { + let expected = u64::try_from(index) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit record sequence", + })? + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider audit record sequence", + })?; + if record.sequence != expected || record.head.body().log_id() != self.log_id { + return Err(IdentityError::StorageCorruption); + } + let actual = auditor.observe(record.head.clone(), record.consistency_proof.as_ref()); + let matches = match (record.status, actual) { + (ProviderAuditStatus::Accepted(expected), Ok(actual)) => expected == actual, + (ProviderAuditStatus::Rollback, Err(IdentityError::ProviderRollback)) + | (ProviderAuditStatus::Equivocation, Err(IdentityError::ProviderEquivocation)) => { + true + } + _ => false, + }; + if !matches { + return Err(IdentityError::StorageCorruption); + } + } + if let Some(head) = &self.latest_head { + head.verify(&self.provider) + .map_err(|_| IdentityError::StorageCorruption)?; + if head.body().log_id() != self.log_id { + return Err(IdentityError::StorageCorruption); + } + } + if let Some(evidence) = &self.equivocation { + evidence + .verify(&self.provider) + .map_err(|_| IdentityError::StorageCorruption)?; + } + if auditor.latest_head() != self.latest_head.as_ref() + || auditor.equivocation_evidence() != self.equivocation.as_ref() + { + return Err(IdentityError::StorageCorruption); + } + if validate_portable_bytes { + crate::provider::interchange::validate_audit_interchange_bounds(self)?; + } + Ok(()) + } + + pub(crate) fn commitment(&self) -> Result { + self.validate()?; + self.commitment_validated() + } + + pub(crate) fn commitment_validated(&self) -> Result { + crate::provider::provider_commitment( + PROVIDER_AUDIT_SNAPSHOT_COMMITMENT_DOMAIN, + &ProviderAuditSnapshotCommitmentWire { + format_version: 1, + revision: self.revision, + provider: &self.provider, + log_id: self.log_id, + latest_head: self.latest_head.as_ref(), + equivocation: self.equivocation.as_ref(), + records: self + .records + .iter() + .map(|record| ProviderAuditRecordCommitmentWire { + sequence: record.sequence, + head: &record.head, + consistency_proof: record.consistency_proof.as_ref(), + status_code: audit_status_code(record.status), + }) + .collect(), + }, + ) + } +} + +impl ProviderAuditRecordWire { + pub(crate) fn from_record(record: &ProviderAuditRecord) -> Self { + Self { + sequence: record.sequence, + head: record.head.clone(), + consistency_proof: record.consistency_proof.clone(), + status_code: audit_status_code(record.status), + } + } + + pub(crate) fn into_record(self) -> Result { + let status = match self.status_code { + 1 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::FirstObserved), + 2 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::TreeAdvanced), + 3 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::HeadRefreshed), + 4 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::Replay), + 5 => ProviderAuditStatus::Rollback, + 6 => ProviderAuditStatus::Equivocation, + code => { + return Err(IdentityError::UnsupportedCodepoint { + registry: "provider audit status", + code, + }); + } + }; + Ok(ProviderAuditRecord { + sequence: self.sequence, + head: self.head, + consistency_proof: self.consistency_proof, + status, + }) + } +} + +#[cfg(feature = "provider-store")] +impl ProviderAuditSnapshotStorageWire { + fn from_snapshot(snapshot: &ProviderAuditSnapshot) -> Result { + snapshot.validate()?; + Ok(Self { + format_version: 1, + revision: snapshot.revision, + provider: snapshot.provider.clone(), + log_id: snapshot.log_id, + latest_head: snapshot.latest_head.clone(), + equivocation: snapshot.equivocation.clone(), + records: BoundedVec::new( + "stored provider audit records", + snapshot + .records + .iter() + .map(ProviderAuditRecordWire::from_record) + .collect(), + )?, + }) + } + + fn into_snapshot(self) -> Result { + if self.format_version != 1 { + return Err(IdentityError::UnsupportedVersion { + version: self.format_version, + }); + } + if self.revision + != u64::try_from(self.records.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider audit revision", + })? + { + return Err(IdentityError::StorageCorruption); + } + provider_audit_snapshot_from_wire_records( + self.provider, + self.log_id, + self.latest_head, + self.equivocation, + self.records.into_vec(), + ) + } +} + +pub(crate) fn provider_audit_snapshot_from_wire_records( + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, + records: Vec, +) -> Result { + let revision = u64::try_from(records.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit interchange revision", + })?; + let snapshot = ProviderAuditSnapshot { + revision, + provider, + log_id, + latest_head, + equivocation, + records: records + .into_iter() + .map(ProviderAuditRecordWire::into_record) + .collect::, _>>()?, + }; + snapshot.validate()?; + Ok(snapshot) +} + +const fn audit_status_code(status: ProviderAuditStatus) -> u16 { + match status { + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::FirstObserved) => 1, + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::TreeAdvanced) => 2, + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::HeadRefreshed) => 3, + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::Replay) => 4, + ProviderAuditStatus::Rollback => 5, + ProviderAuditStatus::Equivocation => 6, + } +} + +/// Authenticated constant-size cursor used by the durable auditor's append hot path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAuditCursor { + revision: u64, + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, +} + +impl ProviderAuditCursor { + /// Build an authenticated cursor without materializing the complete retained journal. + /// + /// Store implementations with normalized metadata can use this constructor after loading + /// their constant-size cursor fields. It verifies the record bound, head signatures, log + /// generation, and terminal-equivocation relationships. + pub fn from_authenticated_parts( + revision: u64, + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, + ) -> Result { + let maximum_revision = u64::try_from(MAX_PROVIDER_AUDIT_RECORDS).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit cursor revision limit", + } + })?; + if revision > maximum_revision + || (revision == 0) != latest_head.is_none() + || (equivocation.is_some() && revision < 2) + { + return Err(IdentityError::StorageCorruption); + } + if let Some(head) = &latest_head { + head.verify(&provider)?; + if head.body().log_id() != log_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit cursor log generation", + }); + } + } + if let Some(evidence) = &equivocation { + evidence.verify(&provider)?; + if evidence.first().body().log_id() != log_id + || evidence.second().body().log_id() != log_id + || latest_head.as_ref() != Some(evidence.first()) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit cursor equivocation", + }); + } + } + Ok(Self { + revision, + provider, + log_id, + latest_head, + equivocation, + }) + } + + /// Build a cursor from a complete snapshot after authenticating the journal. + pub fn from_snapshot(snapshot: &ProviderAuditSnapshot) -> Result { + snapshot.validate()?; + Ok(Self::from_trusted_snapshot(snapshot)) + } + + pub(crate) fn from_trusted_snapshot(snapshot: &ProviderAuditSnapshot) -> Self { + Self { + revision: snapshot.revision, + provider: snapshot.provider.clone(), + log_id: snapshot.log_id, + latest_head: snapshot.latest_head.clone(), + equivocation: snapshot.equivocation.clone(), + } + } + + /// Current durable journal revision. + pub const fn revision(&self) -> u64 { + self.revision + } + + /// Provider authenticating this audit generation. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Exact provider-log generation. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Latest accepted authenticated head. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } + + /// Terminal equivocation evidence, when retained. + pub const fn equivocation_evidence(&self) -> Option<&ProviderEquivocationEvidence> { + self.equivocation.as_ref() + } +} + +/// Exact single-record successor requested by [`ProviderAuditStore::compare_and_append`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAuditAppend { + record: ProviderAuditRecord, + latest_head: Option, + equivocation: Option, +} + +impl ProviderAuditAppend { + /// Exact one-based record being appended. + pub const fn record(&self) -> &ProviderAuditRecord { + &self.record + } + + /// Latest accepted head after applying [`Self::record`]. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } + + /// Terminal evidence after applying [`Self::record`]. + pub const fn equivocation_evidence(&self) -> Option<&ProviderEquivocationEvidence> { + self.equivocation.as_ref() + } +} + +/// Atomic persistence contract used by the runtime-independent durable auditor. +pub trait ProviderAuditStore: Clone + Send + Sync { + /// Load the complete journal for explicit snapshot, export, or recovery work. + fn load(&self) -> Result; + + /// Load the authenticated constant-size current cursor without cloning prior records. + fn load_cursor(&self) -> Result; + + /// Append exactly one validated successor if the revision is still `expected_revision`. + fn compare_and_append( + &self, + expected_revision: u64, + append: ProviderAuditAppend, + ) -> Result<(), IdentityError>; +} + +#[derive(Debug)] +struct MemoryProviderAuditState { + snapshot: ProviderAuditSnapshot, + portable_accounting: crate::provider::interchange::ProviderAuditPortableAccounting, +} + +/// In-memory atomic implementation of [`ProviderAuditStore`]. +#[derive(Debug, Clone)] +pub struct MemoryProviderAuditStore { + state: Arc>, +} + +impl MemoryProviderAuditStore { + /// Create an empty journal for one exact provider/log generation. + pub fn new(provider: ProviderDescriptor, log_id: ProviderLogId) -> Self { + Self { + state: Arc::new(Mutex::new(MemoryProviderAuditState { + snapshot: ProviderAuditSnapshot { + revision: 0, + provider, + log_id, + latest_head: None, + equivocation: None, + records: Vec::new(), + }, + portable_accounting: + crate::provider::interchange::ProviderAuditPortableAccounting::empty(), + })), + } + } + + /// Load the complete current audit snapshot. + pub fn snapshot(&self) -> Result { + self.load() + } + + fn lock_state(&self) -> Result, IdentityError> { + self.state + .lock() + .map_err(|_| IdentityError::StorageCorruption) + } +} + +impl ProviderAuditStore for MemoryProviderAuditStore { + fn load(&self) -> Result { + let snapshot = self.lock_state()?.snapshot.clone(); + snapshot.validate()?; + Ok(snapshot) + } + + fn load_cursor(&self) -> Result { + Ok(ProviderAuditCursor::from_trusted_snapshot( + &self.lock_state()?.snapshot, + )) + } + + fn compare_and_append( + &self, + expected_revision: u64, + append: ProviderAuditAppend, + ) -> Result<(), IdentityError> { + let mut retained = self.lock_state()?; + if retained.snapshot.revision != expected_revision { + return Err(IdentityError::StaleRevision); + } + validate_audit_successor(&retained.snapshot, &append)?; + let next_accounting = retained + .portable_accounting + .with_appended_record(&append.record)?; + apply_audit_append(&mut retained.snapshot, append); + retained.portable_accounting = next_accounting; + Ok(()) + } +} + +fn validate_audit_successor( + retained: &ProviderAuditSnapshot, + append: &ProviderAuditAppend, +) -> Result<(), IdentityError> { + if retained.equivocation.is_some() { + return Err(IdentityError::ProviderEquivocation); + } + let expected_sequence = + retained + .revision + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider audit successor sequence", + })?; + if append.record.sequence != expected_sequence + || append.record.head.body().log_id() != retained.log_id + { + return Err(IdentityError::StorageCorruption); + } + let mut auditor = ProviderHeadAuditor::new(retained.provider.clone(), retained.log_id); + if let Some(latest) = &retained.latest_head + && auditor.observe(latest.clone(), None)? != ProviderHeadAuditDisposition::FirstObserved + { + return Err(IdentityError::StorageCorruption); + } + let actual = auditor.observe( + append.record.head.clone(), + append.record.consistency_proof.as_ref(), + ); + let status_matches = match (append.record.status, actual) { + (ProviderAuditStatus::Accepted(expected), Ok(actual)) => expected == actual, + (ProviderAuditStatus::Rollback, Err(IdentityError::ProviderRollback)) + | (ProviderAuditStatus::Equivocation, Err(IdentityError::ProviderEquivocation)) => true, + _ => false, + }; + if !status_matches + || auditor.latest_head() != append.latest_head.as_ref() + || auditor.equivocation_evidence() != append.equivocation.as_ref() + { + return Err(IdentityError::StorageCorruption); + } + Ok(()) +} + +fn apply_audit_append(snapshot: &mut ProviderAuditSnapshot, append: ProviderAuditAppend) { + snapshot.revision = append.record.sequence; + snapshot.records.push(append.record); + snapshot.latest_head = append.latest_head; + snapshot.equivocation = append.equivocation; +} + +/// Generation-scoped auditor that persists accepted heads and authenticated attacks before return. +#[derive(Debug, Clone)] +pub struct DurableProviderAuditor { + store: S, +} + +impl DurableProviderAuditor { + /// Attach the auditor state machine to an atomic persistence implementation. + pub const fn new(store: S) -> Self { + Self { store } + } + + /// Verify and durably retain one observation without trusting the observed provider. + pub fn observe( + &self, + head: SignedProviderHead, + consistency_proof: Option<&MerkleConsistencyProof>, + ) -> Result { + for _ in 0..=MAX_RETRIES { + let cursor = self.store.load_cursor()?; + if cursor.equivocation.is_some() { + return Err(IdentityError::ProviderEquivocation); + } + let mut auditor = ProviderHeadAuditor::new(cursor.provider.clone(), cursor.log_id); + if let Some(latest) = &cursor.latest_head { + let seeded = auditor.observe(latest.clone(), None)?; + if seeded != ProviderHeadAuditDisposition::FirstObserved { + return Err(IdentityError::StorageCorruption); + } + } + let result = auditor.observe(head.clone(), consistency_proof); + let status = match &result { + Ok(disposition) => ProviderAuditStatus::Accepted(*disposition), + Err(IdentityError::ProviderRollback) => ProviderAuditStatus::Rollback, + Err(IdentityError::ProviderEquivocation) => ProviderAuditStatus::Equivocation, + Err(error) => return Err(error.clone()), + }; + let retained_records = usize::try_from(cursor.revision).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit retained records", + } + })?; + if retained_records >= MAX_PROVIDER_AUDIT_RECORDS { + return Err(IdentityError::limit( + "provider audit records", + retained_records.saturating_add(1), + MAX_PROVIDER_AUDIT_RECORDS, + )); + } + let next_revision = + cursor + .revision + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider audit revision", + })?; + let append = ProviderAuditAppend { + record: ProviderAuditRecord { + sequence: next_revision, + head: head.clone(), + consistency_proof: consistency_proof.cloned(), + status, + }, + latest_head: auditor.latest_head().cloned(), + equivocation: auditor.equivocation_evidence().cloned(), + }; + match self.store.compare_and_append(cursor.revision, append) { + Ok(()) => return result, + Err(IdentityError::StaleRevision) => continue, + Err(error) => return Err(error), + } + } + Err(IdentityError::ResourceBusy) + } + + /// Load the complete durable journal through the configured persistence boundary. + pub fn snapshot(&self) -> Result { + self.store.load() + } +} + +#[cfg(test)] +mod tests { + use krikos_base::SecretKey; + + use super::*; + use crate::{ + CanonicalWire, Extensions, HashAlgorithm, ProtocolSignature, ProviderHeadBody, + ProviderKeyVersion, SigningPublicKey, Timestamp, + }; + + #[derive(serde::Serialize)] + struct AuditArtifactCommitmentMirror<'a> { + format_version: u16, + sequence: u64, + kind_code: u16, + accepted_head: &'a SignedProviderHead, + observed_head: &'a SignedProviderHead, + } + + #[derive(serde::Serialize)] + struct AuditRecordCommitmentMirror<'a> { + sequence: u64, + head: &'a SignedProviderHead, + consistency_proof: Option<&'a MerkleConsistencyProof>, + status_code: u16, + } + + #[derive(serde::Serialize)] + struct AuditSnapshotCommitmentMirror<'a> { + format_version: u16, + revision: u64, + provider: &'a ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option<&'a SignedProviderHead>, + equivocation: Option<&'a ProviderEquivocationEvidence>, + records: Vec>, + } + + fn raw_commitment(domain: &[u8], value: &T) -> Digest { + let bytes = postcard::to_stdvec(value).unwrap(); + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(&bytes); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) + } + + fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() + } + + fn signed_head( + provider: &ProviderDescriptor, + log_id: ProviderLogId, + root_fill: u8, + observed_at: u64, + signer: &SecretKey, + ) -> SignedProviderHead { + let body = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + 1, + Digest::new(HashAlgorithm::Blake3_256, [root_fill; 32]), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + let signature = + ProtocolSignature::ed25519(signer.sign(&body.signing_bytes().unwrap()).to_bytes()); + SignedProviderHead::new(body, signature) + } + + #[test] + fn repeated_memory_audit_appends_encode_only_the_new_record() { + const OBSERVATION_COUNT: u64 = 257; + + let signer = SecretKey::from_bytes(&[0x21; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x22); + let store = MemoryProviderAuditStore::new(provider.clone(), log_id); + let auditor = DurableProviderAuditor::new(store); + crate::provider::interchange::reset_portable_audit_record_encoding_count(); + + for index in 0..OBSERVATION_COUNT { + auditor + .observe( + signed_head(&provider, log_id, 0x23, index + 1, &signer), + None, + ) + .unwrap(); + } + + assert_eq!( + crate::provider::interchange::portable_audit_record_encoding_count(), + usize::try_from(OBSERVATION_COUNT).unwrap(), + "each audit append must encode only its new record" + ); + } + + #[test] + fn provider_audit_commitments_use_versioned_canonical_preimages() { + let signer = SecretKey::from_bytes(&[0x31; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x32); + let accepted = signed_head(&provider, log_id, 0x33, 100, &signer); + let conflict = signed_head(&provider, log_id, 0x34, 101, &signer); + let store = MemoryProviderAuditStore::new(provider.clone(), log_id); + let auditor = DurableProviderAuditor::new(store.clone()); + assert_eq!( + auditor.observe(accepted.clone(), None), + Ok(ProviderHeadAuditDisposition::FirstObserved) + ); + assert_eq!( + auditor.observe(conflict.clone(), None), + Err(IdentityError::ProviderEquivocation) + ); + + let snapshot = store.snapshot().unwrap(); + let artifacts = snapshot.artifacts().unwrap(); + let artifact = artifacts.first().unwrap(); + assert_eq!(artifact.kind(), ProviderAuditArtifactKind::Equivocation); + let expected_artifact = raw_commitment( + b"KRIKOS-ID/provider-audit-artifact/v1", + &AuditArtifactCommitmentMirror { + format_version: 1, + sequence: artifact.sequence(), + kind_code: 2, + accepted_head: artifact.accepted_head(), + observed_head: artifact.observed_head(), + }, + ); + assert_eq!(artifact.commitment().unwrap(), expected_artifact); + + let records = snapshot.records(); + assert_eq!(records.len(), 2); + let expected_snapshot = raw_commitment( + b"KRIKOS-ID/provider-audit-snapshot/v1", + &AuditSnapshotCommitmentMirror { + format_version: 1, + revision: snapshot.revision(), + provider: snapshot.provider(), + log_id: snapshot.log_id(), + latest_head: snapshot.latest_head(), + equivocation: snapshot.equivocation_evidence(), + records: vec![ + AuditRecordCommitmentMirror { + sequence: records[0].sequence(), + head: records[0].head(), + consistency_proof: records[0].consistency_proof(), + status_code: 1, + }, + AuditRecordCommitmentMirror { + sequence: records[1].sequence(), + head: records[1].head(), + consistency_proof: records[1].consistency_proof(), + status_code: 6, + }, + ], + }, + ); + assert_eq!(snapshot.commitment().unwrap(), expected_snapshot); + } +} diff --git a/protocols/krikos-identity/src/audit/redb.rs b/protocols/krikos-identity/src/audit/redb.rs new file mode 100644 index 00000000000..f1a329fbfd0 --- /dev/null +++ b/protocols/krikos-identity/src/audit/redb.rs @@ -0,0 +1,698 @@ +//! Redb persistence for provider audit journals. + +#[cfg(test)] +use std::cell::Cell; +use std::{ + path::Path, + sync::{Arc, Mutex, MutexGuard}, +}; + +use redb::{ + Database, ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition, TableHandle, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + MAX_PROVIDER_AUDIT_RECORDS, ProviderAuditAppend, ProviderAuditCursor, ProviderAuditRecord, + ProviderAuditSnapshot, ProviderAuditStatus, ProviderAuditStore, apply_audit_append, + validate_audit_successor, +}; +use crate::{ + IdentityError, ProviderDescriptor, ProviderEquivocationEvidence, ProviderHeadAuditDisposition, + ProviderLogId, SignedProviderHead, + codec::{decode_wire, encode_wire}, + merkle::MerkleConsistencyProof, + provider::interchange::ProviderAuditPortableAccounting, +}; + +const LEGACY_AUDIT_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-provider-audit-v1"); +const AUDIT_METADATA_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-provider-audit-metadata-v2"); +const AUDIT_RECORD_TABLE: TableDefinition = + TableDefinition::new("krikos-provider-audit-records-v2"); +const AUDIT_METADATA_KEY: &[u8] = b"journal"; +const AUDIT_VERSION: u16 = 2; +const MAX_AUDIT_METADATA_BYTES: usize = 64 * 1024; +const MAX_AUDIT_RECORD_BYTES: usize = 4 * 1024 * 1024; + +#[cfg(test)] +thread_local! { + static STORED_AUDIT_RECORD_DECODING_COUNT: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +fn reset_stored_audit_record_decoding_count() { + STORED_AUDIT_RECORD_DECODING_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +fn stored_audit_record_decoding_count() -> usize { + STORED_AUDIT_RECORD_DECODING_COUNT.with(Cell::get) +} + +fn record_stored_audit_record_decoding() { + #[cfg(test)] + STORED_AUDIT_RECORD_DECODING_COUNT.with(|count| count.set(count.get().saturating_add(1))); +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct AuditRecordWire { + sequence: u64, + head: SignedProviderHead, + consistency_proof: Option, + status_code: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct AuditMetadataWire { + version: u16, + revision: u64, + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, +} + +#[derive(Debug)] +struct CachedAuditState { + snapshot: ProviderAuditSnapshot, + portable_accounting: ProviderAuditPortableAccounting, +} + +impl AuditRecordWire { + fn from_record(record: &ProviderAuditRecord) -> Self { + let status_code = match record.status { + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::FirstObserved) => 1, + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::TreeAdvanced) => 2, + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::HeadRefreshed) => 3, + ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::Replay) => 4, + ProviderAuditStatus::Rollback => 5, + ProviderAuditStatus::Equivocation => 6, + }; + Self { + sequence: record.sequence, + head: record.head.clone(), + consistency_proof: record.consistency_proof.clone(), + status_code, + } + } + + fn into_record(self) -> Result { + let status = match self.status_code { + 1 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::FirstObserved), + 2 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::TreeAdvanced), + 3 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::HeadRefreshed), + 4 => ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::Replay), + 5 => ProviderAuditStatus::Rollback, + 6 => ProviderAuditStatus::Equivocation, + _ => return Err(IdentityError::StorageCorruption), + }; + Ok(ProviderAuditRecord { + sequence: self.sequence, + head: self.head, + consistency_proof: self.consistency_proof, + status, + }) + } +} + +impl AuditMetadataWire { + fn empty(provider: ProviderDescriptor, log_id: ProviderLogId) -> Self { + Self { + version: AUDIT_VERSION, + revision: 0, + provider, + log_id, + latest_head: None, + equivocation: None, + } + } + + fn from_snapshot(snapshot: &ProviderAuditSnapshot) -> Self { + Self { + version: AUDIT_VERSION, + revision: snapshot.revision, + provider: snapshot.provider.clone(), + log_id: snapshot.log_id, + latest_head: snapshot.latest_head.clone(), + equivocation: snapshot.equivocation.clone(), + } + } + + fn after_append(retained: &ProviderAuditSnapshot, append: &ProviderAuditAppend) -> Self { + Self { + version: AUDIT_VERSION, + revision: append.record.sequence, + provider: retained.provider.clone(), + log_id: retained.log_id, + latest_head: append.latest_head.clone(), + equivocation: append.equivocation.clone(), + } + } +} + +/// Redb-backed atomic provider audit journal. +#[derive(Debug, Clone)] +pub struct RedbProviderAuditStore { + database: Arc, + provider: ProviderDescriptor, + log_id: ProviderLogId, + cache: Arc>, +} + +impl RedbProviderAuditStore { + /// Open or create one exact provider/log audit generation and authenticate its complete journal. + pub fn open( + path: impl AsRef, + provider: ProviderDescriptor, + log_id: ProviderLogId, + ) -> Result { + let path = path.as_ref(); + crate::redb_guard::validate_existing_redb_file(path)?; + let database = Database::create(path).map_err(|_| IdentityError::StorageCorruption)?; + let write = database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + if write + .list_tables() + .map_err(|_| IdentityError::StorageCorruption)? + .any(|table| table.name() == LEGACY_AUDIT_TABLE.name()) + { + return Err(IdentityError::StorageCorruption); + } + let metadata_exists = { + let metadata = write + .open_table(AUDIT_METADATA_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + metadata + .get(AUDIT_METADATA_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .is_some() + }; + if !metadata_exists { + let records = write + .open_table(AUDIT_RECORD_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + if !records + .is_empty() + .map_err(|_| IdentityError::StorageCorruption)? + { + return Err(IdentityError::StorageCorruption); + } + drop(records); + let metadata = AuditMetadataWire::empty(provider.clone(), log_id); + let bytes = encode_metadata(&metadata)?; + let mut table = write + .open_table(AUDIT_METADATA_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .insert(AUDIT_METADATA_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + { + let _records = write + .open_table(AUDIT_RECORD_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + + let cached = load_authoritative(&database)?; + if cached.snapshot.provider != provider || cached.snapshot.log_id != log_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit generation", + }); + } + Ok(Self { + database: Arc::new(database), + provider, + log_id, + cache: Arc::new(Mutex::new(cached)), + }) + } + + /// Load and fully reauthenticate the complete current durable audit state. + pub fn snapshot(&self) -> Result { + self.load() + } + + fn lock_cache(&self) -> Result, IdentityError> { + self.cache + .lock() + .map_err(|_| IdentityError::StorageCorruption) + } + + fn refresh_cache(&self) -> Result { + let refreshed = load_authoritative(&self.database)?; + if refreshed.snapshot.provider != self.provider || refreshed.snapshot.log_id != self.log_id + { + return Err(IdentityError::StorageCorruption); + } + Ok(refreshed) + } +} + +impl ProviderAuditStore for RedbProviderAuditStore { + fn load(&self) -> Result { + let refreshed = self.refresh_cache()?; + let snapshot = refreshed.snapshot.clone(); + *self.lock_cache()? = refreshed; + Ok(snapshot) + } + + fn load_cursor(&self) -> Result { + Ok(ProviderAuditCursor::from_trusted_snapshot( + &self.lock_cache()?.snapshot, + )) + } + + fn compare_and_append( + &self, + expected_revision: u64, + append: ProviderAuditAppend, + ) -> Result<(), IdentityError> { + let mut cached = self.lock_cache()?; + if cached.snapshot.revision != expected_revision { + return Err(IdentityError::StaleRevision); + } + validate_audit_successor(&cached.snapshot, &append)?; + let next_accounting = cached + .portable_accounting + .with_appended_record(&append.record)?; + let record_bytes = encode_record(&append.record)?; + let next_metadata = AuditMetadataWire::after_append(&cached.snapshot, &append); + let metadata_bytes = encode_metadata(&next_metadata)?; + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let durable_metadata = { + let metadata = write + .open_table(AUDIT_METADATA_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let value = metadata + .get(AUDIT_METADATA_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + decode_metadata(value.value())? + }; + if durable_metadata.revision != expected_revision { + drop(write); + *cached = self.refresh_cache()?; + return Err(IdentityError::StaleRevision); + } + if durable_metadata != AuditMetadataWire::from_snapshot(&cached.snapshot) { + return Err(IdentityError::StorageCorruption); + } + { + let mut records = write + .open_table(AUDIT_RECORD_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + if records + .get(append.record.sequence) + .map_err(|_| IdentityError::StorageCorruption)? + .is_some() + { + return Err(IdentityError::StorageCorruption); + } + records + .insert(append.record.sequence, record_bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + { + let mut metadata = write + .open_table(AUDIT_METADATA_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + metadata + .insert(AUDIT_METADATA_KEY, metadata_bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + apply_audit_append(&mut cached.snapshot, append); + cached.portable_accounting = next_accounting; + Ok(()) + } +} + +fn load_authoritative(database: &Database) -> Result { + let read = database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let metadata = { + let table = read + .open_table(AUDIT_METADATA_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let value = table + .get(AUDIT_METADATA_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + decode_metadata(value.value())? + }; + let maximum_revision = u64::try_from(MAX_PROVIDER_AUDIT_RECORDS).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "stored provider audit record limit", + } + })?; + if metadata.version != AUDIT_VERSION || metadata.revision > maximum_revision { + return Err(IdentityError::StorageCorruption); + } + let capacity = + usize::try_from(metadata.revision).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider audit record allocation", + })?; + let table = read + .open_table(AUDIT_RECORD_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let mut records = Vec::with_capacity(capacity); + let mut expected_sequence = 1_u64; + for result in table.iter().map_err(|_| IdentityError::StorageCorruption)? { + let (key, value) = result.map_err(|_| IdentityError::StorageCorruption)?; + if expected_sequence > metadata.revision || expected_sequence > maximum_revision { + return Err(IdentityError::StorageCorruption); + } + if key.value() != expected_sequence { + return Err(IdentityError::StorageCorruption); + } + let record = decode_record(value.value())?; + if record.sequence != expected_sequence { + return Err(IdentityError::StorageCorruption); + } + records.push(record); + expected_sequence = + expected_sequence + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider audit record sequence", + })?; + } + if u64::try_from(records.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider audit record count", + })? != metadata.revision + { + return Err(IdentityError::StorageCorruption); + } + let snapshot = ProviderAuditSnapshot { + revision: metadata.revision, + provider: metadata.provider, + log_id: metadata.log_id, + latest_head: metadata.latest_head, + equivocation: metadata.equivocation, + records, + }; + snapshot.validate_cached()?; + let portable_accounting = ProviderAuditPortableAccounting::from_snapshot(&snapshot)?; + Ok(CachedAuditState { + snapshot, + portable_accounting, + }) +} + +fn encode_metadata(metadata: &AuditMetadataWire) -> Result, IdentityError> { + let bytes = encode_wire(metadata).map_err(|_| IdentityError::StorageCorruption)?; + if bytes.len() > MAX_AUDIT_METADATA_BYTES { + return Err(IdentityError::limit( + "stored provider audit metadata bytes", + bytes.len(), + MAX_AUDIT_METADATA_BYTES, + )); + } + Ok(bytes) +} + +fn decode_metadata(bytes: &[u8]) -> Result { + if bytes.len() > MAX_AUDIT_METADATA_BYTES { + return Err(IdentityError::StorageCorruption); + } + let metadata: AuditMetadataWire = + decode_wire(bytes).map_err(|_| IdentityError::StorageCorruption)?; + if metadata.version != AUDIT_VERSION { + return Err(IdentityError::StorageCorruption); + } + Ok(metadata) +} + +fn encode_record(record: &ProviderAuditRecord) -> Result, IdentityError> { + let bytes = encode_wire(&AuditRecordWire::from_record(record)) + .map_err(|_| IdentityError::StorageCorruption)?; + if bytes.len() > MAX_AUDIT_RECORD_BYTES { + return Err(IdentityError::limit( + "stored provider audit record bytes", + bytes.len(), + MAX_AUDIT_RECORD_BYTES, + )); + } + Ok(bytes) +} + +fn decode_record(bytes: &[u8]) -> Result { + record_stored_audit_record_decoding(); + if bytes.len() > MAX_AUDIT_RECORD_BYTES { + return Err(IdentityError::StorageCorruption); + } + decode_wire::(bytes) + .map_err(|_| IdentityError::StorageCorruption)? + .into_record() +} + +#[cfg(test)] +mod tests { + use krikos_base::SecretKey; + use redb::ReadableTableMetadata; + + use super::*; + use crate::{ + CanonicalWire, Digest, DurableProviderAuditor, Extensions, HashAlgorithm, + ProtocolSignature, ProviderHeadBody, ProviderKeyVersion, SigningPublicKey, Timestamp, + }; + + fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() + } + + fn provider(signer: &SecretKey) -> ProviderDescriptor { + ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap() + } + + fn signed_head( + provider: &ProviderDescriptor, + log_id: ProviderLogId, + root_fill: u8, + observed_at: u64, + signer: &SecretKey, + ) -> SignedProviderHead { + let body = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + 1, + Digest::new(HashAlgorithm::Blake3_256, [root_fill; 32]), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + let signature = + ProtocolSignature::ed25519(signer.sign(&body.signing_bytes().unwrap()).to_bytes()); + SignedProviderHead::new(body, signature) + } + + fn first_append(head: SignedProviderHead) -> ProviderAuditAppend { + ProviderAuditAppend { + record: ProviderAuditRecord { + sequence: 1, + head: head.clone(), + consistency_proof: None, + status: ProviderAuditStatus::Accepted(ProviderHeadAuditDisposition::FirstObserved), + }, + latest_head: Some(head), + equivocation: None, + } + } + + #[test] + fn rejects_legacy_v1_monolithic_journal() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("legacy-audit.redb"); + { + let database = Database::create(&path).unwrap(); + let write = database.begin_write().unwrap(); + { + let mut table = write.open_table(LEGACY_AUDIT_TABLE).unwrap(); + table + .insert(b"journal".as_slice(), b"v1".as_slice()) + .unwrap(); + } + write.commit().unwrap(); + } + let signer = SecretKey::from_bytes(&[0x41; 32]); + let provider = provider(&signer); + let log_id = typed_id::(0x42); + + assert!(matches!( + RedbProviderAuditStore::open(path, provider, log_id), + Err(IdentityError::StorageCorruption) + )); + } + + #[test] + fn rejects_an_extra_record_before_decoding_it() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("extra-audit-record.redb"); + let signer = SecretKey::from_bytes(&[0x43; 32]); + let provider = provider(&signer); + let log_id = typed_id::(0x44); + drop(RedbProviderAuditStore::open(&path, provider.clone(), log_id).unwrap()); + { + let database = Database::create(&path).unwrap(); + let write = database.begin_write().unwrap(); + { + let mut table = write.open_table(AUDIT_RECORD_TABLE).unwrap(); + table.insert(1, b"not-a-record".as_slice()).unwrap(); + } + write.commit().unwrap(); + } + reset_stored_audit_record_decoding_count(); + + assert!(matches!( + RedbProviderAuditStore::open(path, provider, log_id), + Err(IdentityError::StorageCorruption) + )); + assert_eq!(stored_audit_record_decoding_count(), 0); + } + + #[test] + fn rejects_a_noncontiguous_record_table() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("gapped-audit-record.redb"); + let signer = SecretKey::from_bytes(&[0x45; 32]); + let provider = provider(&signer); + let log_id = typed_id::(0x46); + drop(RedbProviderAuditStore::open(&path, provider.clone(), log_id).unwrap()); + { + let database = Database::create(&path).unwrap(); + let write = database.begin_write().unwrap(); + { + let metadata = AuditMetadataWire { + version: AUDIT_VERSION, + revision: 2, + provider: provider.clone(), + log_id, + latest_head: None, + equivocation: None, + }; + let bytes = encode_metadata(&metadata).unwrap(); + let mut table = write.open_table(AUDIT_METADATA_TABLE).unwrap(); + table.insert(AUDIT_METADATA_KEY, bytes.as_slice()).unwrap(); + } + { + let mut table = write.open_table(AUDIT_RECORD_TABLE).unwrap(); + table.insert(2, b"not-a-record".as_slice()).unwrap(); + } + write.commit().unwrap(); + } + reset_stored_audit_record_decoding_count(); + + assert!(matches!( + RedbProviderAuditStore::open(path, provider, log_id), + Err(IdentityError::StorageCorruption) + )); + assert_eq!(stored_audit_record_decoding_count(), 0); + } + + #[test] + fn stale_cas_refreshes_cache_without_partially_appending() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("stale-cas-audit.redb"); + let signer = SecretKey::from_bytes(&[0x47; 32]); + let provider = provider(&signer); + let log_id = typed_id::(0x48); + let first = RedbProviderAuditStore::open(&path, provider.clone(), log_id).unwrap(); + let stale = RedbProviderAuditStore { + database: Arc::clone(&first.database), + provider: provider.clone(), + log_id, + cache: Arc::new(Mutex::new(load_authoritative(&first.database).unwrap())), + }; + let durable_head = signed_head(&provider, log_id, 0x49, 1, &signer); + first + .compare_and_append(0, first_append(durable_head.clone())) + .unwrap(); + let rejected_head = signed_head(&provider, log_id, 0x49, 2, &signer); + + assert_eq!( + stale.compare_and_append(0, first_append(rejected_head)), + Err(IdentityError::StaleRevision) + ); + let refreshed = stale.load_cursor().unwrap(); + assert_eq!(refreshed.revision(), 1); + assert_eq!(refreshed.latest_head(), Some(&durable_head)); + let read = first.database.begin_read().unwrap(); + let records = read.open_table(AUDIT_RECORD_TABLE).unwrap(); + assert_eq!(records.len().unwrap(), 1); + assert!(records.get(1).unwrap().is_some()); + assert!(records.get(2).unwrap().is_none()); + let metadata = read.open_table(AUDIT_METADATA_TABLE).unwrap(); + let value = metadata.get(AUDIT_METADATA_KEY).unwrap().unwrap(); + assert_eq!(decode_metadata(value.value()).unwrap().revision, 1); + } + + #[test] + fn cross_reopen_257_appends_do_not_rescan_prior_records() { + const FIRST_BATCH: u64 = 129; + const OBSERVATION_COUNT: u64 = 257; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("linear-audit.redb"); + let signer = SecretKey::from_bytes(&[0x4a; 32]); + let provider = provider(&signer); + let log_id = typed_id::(0x4b); + { + let store = RedbProviderAuditStore::open(&path, provider.clone(), log_id).unwrap(); + let auditor = DurableProviderAuditor::new(store); + for index in 0..FIRST_BATCH { + auditor + .observe( + signed_head(&provider, log_id, 0x4c, index + 1, &signer), + None, + ) + .unwrap(); + } + } + + crate::provider::interchange::reset_portable_audit_record_encoding_count(); + reset_stored_audit_record_decoding_count(); + let store = RedbProviderAuditStore::open(&path, provider.clone(), log_id).unwrap(); + let auditor = DurableProviderAuditor::new(store.clone()); + for index in FIRST_BATCH..OBSERVATION_COUNT { + auditor + .observe( + signed_head(&provider, log_id, 0x4c, index + 1, &signer), + None, + ) + .unwrap(); + } + + assert_eq!(store.load_cursor().unwrap().revision(), OBSERVATION_COUNT); + assert_eq!( + crate::provider::interchange::portable_audit_record_encoding_count(), + usize::try_from(OBSERVATION_COUNT).unwrap(), + "reopen recounts once and each later append encodes only its new record" + ); + assert_eq!( + stored_audit_record_decoding_count(), + usize::try_from(FIRST_BATCH).unwrap(), + "later appends must not decode any retained record" + ); + let read = store.database.begin_read().unwrap(); + let records = read.open_table(AUDIT_RECORD_TABLE).unwrap(); + assert_eq!(records.len().unwrap(), OBSERVATION_COUNT); + } +} diff --git a/protocols/krikos-identity/src/capability.rs b/protocols/krikos-identity/src/capability.rs new file mode 100644 index 00000000000..7bcc4e7a4ca --- /dev/null +++ b/protocols/krikos-identity/src/capability.rs @@ -0,0 +1,1214 @@ +//! Bounded capability grants and structurally validated delegation chains. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + AccountId, CapabilityGrantId, CheckpointId, DelegationDepth, DelegationId, DeviceId, Epoch, + Extensions, IdentityError, ProtocolSignature, ProtocolVersion, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{ + MAX_CAPABILITY_NAME_BYTES, MAX_CONSTRAINTS_PER_CAPABILITY, MAX_DELEGATION_DEPTH, + MAX_RESOURCE_SELECTOR_BYTES, + }, + schema::{BoundedBytes, BoundedVec}, +}; + +/// Maximum number of nonempty segments in one v1 resource path. +pub const MAX_RESOURCE_PATH_SEGMENTS: usize = 64; + +macro_rules! canonical_schema { + ($name:ty, $resource:literal) => { + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +macro_rules! bounded_capability_name { + ($name:ident, $resource:literal, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] + pub struct $name(Box); + + impl $name { + /// Construct a nonempty, byte-for-byte v1 name. + pub fn new(value: impl AsRef) -> Result { + let value = value.as_ref(); + if value.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: $resource, + }); + } + if value.len() > MAX_CAPABILITY_NAME_BYTES { + return Err(IdentityError::limit( + $resource, + value.len(), + MAX_CAPABILITY_NAME_BYTES, + )); + } + Ok(Self(Box::from(value))) + } + + /// Borrow the exact UTF-8 name bytes as text. + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = $name; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "a nonempty UTF-8 name of at most {MAX_CAPABILITY_NAME_BYTES} bytes" + ) + } + + fn visit_borrowed_str(self, value: &'de str) -> Result + where + E: de::Error, + { + $name::new(value).map_err(E::custom) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + $name::new(value).map_err(E::custom) + } + } + + deserializer.deserialize_str(Visitor) + } + } + + canonical_schema!($name, $resource); + }; +} + +bounded_capability_name!( + CapabilityNamespace, + "capability namespace bytes", + "An exact, nonempty UTF-8 capability namespace." +); +bounded_capability_name!( + CapabilityAction, + "capability action bytes", + "An exact, nonempty UTF-8 capability action." +); + +/// One nonempty opaque byte segment in a capability resource path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResourceSegment(BoundedBytes); + +impl ResourceSegment { + /// Construct one bounded, nonempty opaque segment. + pub fn new(bytes: Vec) -> Result { + if bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "resource path segment bytes", + }); + } + Ok(Self(BoundedBytes::new( + "resource path segment bytes", + bytes, + )?)) + } + + /// Borrow the exact opaque segment bytes. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl<'de> Deserialize<'de> for ResourceSegment { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let bytes = BoundedBytes::::deserialize(deserializer)?; + Self::new(bytes.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(ResourceSegment, "resource path segment bytes"); + +/// A semantic-order sequence of nonempty opaque resource path segments. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResourcePath(Vec); + +impl ResourcePath { + /// Construct a path from semantic-order opaque segment bytes. + pub fn new(segments: Vec>) -> Result { + if segments.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "resource path segments", + }); + } + if segments.len() > MAX_RESOURCE_PATH_SEGMENTS { + return Err(IdentityError::limit( + "resource path segments", + segments.len(), + MAX_RESOURCE_PATH_SEGMENTS, + )); + } + let segments = segments + .into_iter() + .map(ResourceSegment::new) + .collect::, _>>()?; + Self::from_segments(segments) + } + + fn from_segments(segments: Vec) -> Result { + if segments.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "resource path segments", + }); + } + if segments.len() > MAX_RESOURCE_PATH_SEGMENTS { + return Err(IdentityError::limit( + "resource path segments", + segments.len(), + MAX_RESOURCE_PATH_SEGMENTS, + )); + } + let path = Self(segments); + path.validate_selector_size()?; + Ok(path) + } + + /// Borrow path segments in their semantic order. + pub fn segments(&self) -> &[ResourceSegment] { + &self.0 + } + + fn validate_selector_size(&self) -> Result<(), IdentityError> { + let mut encoded_len = 1usize.checked_add(varint_len(self.0.len())).ok_or( + IdentityError::ArithmeticOverflow { + resource: "resource selector bytes", + }, + )?; + for segment in &self.0 { + encoded_len = encoded_len + .checked_add(varint_len(segment.as_bytes().len())) + .and_then(|length| length.checked_add(segment.as_bytes().len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "resource selector bytes", + })?; + if encoded_len > MAX_RESOURCE_SELECTOR_BYTES { + return Err(IdentityError::limit( + "resource selector bytes", + encoded_len, + MAX_RESOURCE_SELECTOR_BYTES, + )); + } + } + Ok(()) + } + + fn starts_with(&self, prefix: &Self) -> bool { + self.0.len() >= prefix.0.len() + && self + .0 + .iter() + .zip(&prefix.0) + .all(|(segment, prefix_segment)| segment == prefix_segment) + } +} + +impl<'de> Deserialize<'de> for ResourcePath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let segments = + BoundedVec::::deserialize(deserializer)?; + Self::from_segments(segments.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(ResourcePath, "resource path bytes"); + +const fn varint_len(mut value: usize) -> usize { + let mut length = 1; + while value >= 128 { + value >>= 7; + length += 1; + } + length +} + +/// A closed v1 exact or complete-segment-prefix resource selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResourceSelector { + /// Select exactly one resource path. + Exact(ResourcePath), + /// Select every resource path beginning with these complete segments. + Prefix(ResourcePath), +} + +impl ResourceSelector { + /// Construct an exact selector after rechecking its encoded size. + pub fn exact(path: ResourcePath) -> Result { + path.validate_selector_size()?; + Ok(Self::Exact(path)) + } + + /// Construct a complete-segment-prefix selector after rechecking its encoded size. + pub fn prefix(path: ResourcePath) -> Result { + path.validate_selector_size()?; + Ok(Self::Prefix(path)) + } + + /// Stable closed v1 selector codepoint. + pub const fn code(&self) -> u16 { + match self { + Self::Exact(_) => 1, + Self::Prefix(_) => 2, + } + } + + /// Borrow the selected path or prefix. + pub const fn path(&self) -> &ResourcePath { + match self { + Self::Exact(path) | Self::Prefix(path) => path, + } + } +} + +impl Serialize for ResourceSelector { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.code(), self.path()).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ResourceSelector { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, path) = <(u16, ResourcePath)>::deserialize(deserializer)?; + match code { + 1 => Self::exact(path).map_err(de::Error::custom), + 2 => Self::prefix(path).map_err(de::Error::custom), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "resource selector", + code: unsupported, + })), + } + } +} + +impl CanonicalCodec for ResourceSelector { + const RESOURCE: &'static str = "resource selector bytes"; + const MAX_ENCODED_BYTES: usize = MAX_RESOURCE_SELECTOR_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// A closed v1 conjunctive capability constraint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CapabilityConstraint { + /// Require an authorization account epoch at least this value. + AccountEpochAtLeast(Epoch), + /// Require an authorization account epoch at most this value. + AccountEpochAtMost(Epoch), + /// Require use no earlier than this explicit timestamp. + ValidFrom(Timestamp), +} + +impl CapabilityConstraint { + /// Stable closed v1 constraint codepoint. + pub const fn code(self) -> u16 { + match self { + Self::AccountEpochAtLeast(_) => 1, + Self::AccountEpochAtMost(_) => 2, + Self::ValidFrom(_) => 3, + } + } + + const fn value(self) -> u64 { + match self { + Self::AccountEpochAtLeast(epoch) | Self::AccountEpochAtMost(epoch) => epoch.get(), + Self::ValidFrom(timestamp) => timestamp.as_unix_millis(), + } + } +} + +impl Serialize for CapabilityConstraint { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.code(), self.value()).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for CapabilityConstraint { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, value) = <(u16, u64)>::deserialize(deserializer)?; + match code { + 1 => Ok(Self::AccountEpochAtLeast(Epoch::new(value))), + 2 => Ok(Self::AccountEpochAtMost(Epoch::new(value))), + 3 => Ok(Self::ValidFrom(Timestamp::from_unix_millis(value))), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "capability constraint", + code: unsupported, + })), + } + } +} + +canonical_schema!(CapabilityConstraint, "capability constraint bytes"); + +/// Whether and how far a capability grant may be delegated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DelegationPermission { + /// This grant cannot be delegated further. + NotDelegable, + /// This grant can be delegated with bounded remaining depth. + Delegable { + /// Maximum number of remaining delegation links. + remaining: DelegationDepth, + }, +} + +impl DelegationPermission { + /// Construct a permission with a previously validated remaining depth. + pub const fn delegable(remaining: DelegationDepth) -> Self { + Self::Delegable { remaining } + } + + /// Stable closed v1 delegation-permission codepoint. + pub const fn code(self) -> u16 { + match self { + Self::NotDelegable => 1, + Self::Delegable { .. } => 2, + } + } + + /// Return the remaining depth, or `None` when delegation is forbidden. + pub const fn remaining(self) -> Option { + match self { + Self::NotDelegable => None, + Self::Delegable { remaining } => Some(remaining), + } + } +} + +impl Serialize for DelegationPermission { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let remaining = self.remaining().map_or(0, DelegationDepth::get); + (self.code(), remaining).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DelegationPermission { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, remaining) = <(u16, u8)>::deserialize(deserializer)?; + match (code, remaining) { + (1, 0) => Ok(Self::NotDelegable), + (1, _) => Err(de::Error::custom(IdentityError::InvalidCapability { + reason: "non-delegable permission has nonzero remaining depth", + })), + (2, remaining) => DelegationDepth::new(remaining) + .map(Self::delegable) + .map_err(de::Error::custom), + (unsupported, _) => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "delegation permission", + code: unsupported, + })), + } + } +} + +canonical_schema!(DelegationPermission, "delegation permission bytes"); + +/// A canonical, bounded v1 capability grant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CapabilityGrant { + protocol_version: ProtocolVersion, + namespace: CapabilityNamespace, + action: CapabilityAction, + resource: ResourceSelector, + constraints: Vec, + delegation: DelegationPermission, + expires_at: Option, + extensions: Extensions, +} + +impl CapabilityGrant { + /// Validate, canonically sort, and construct a v1 capability grant. + #[allow(clippy::too_many_arguments)] + pub fn new( + namespace: CapabilityNamespace, + action: CapabilityAction, + resource: ResourceSelector, + mut constraints: Vec, + delegation: DelegationPermission, + expires_at: Option, + extensions: Extensions, + ) -> Result { + if constraints.len() > MAX_CONSTRAINTS_PER_CAPABILITY { + return Err(IdentityError::limit( + "capability constraints", + constraints.len(), + MAX_CONSTRAINTS_PER_CAPABILITY, + )); + } + constraints.sort_unstable_by_key(|constraint| constraint.code()); + Self::from_sorted( + namespace, + action, + resource, + constraints, + delegation, + expires_at, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_sorted( + namespace: CapabilityNamespace, + action: CapabilityAction, + resource: ResourceSelector, + constraints: Vec, + delegation: DelegationPermission, + expires_at: Option, + extensions: Extensions, + ) -> Result { + if constraints.len() > MAX_CONSTRAINTS_PER_CAPABILITY { + return Err(IdentityError::limit( + "capability constraints", + constraints.len(), + MAX_CONSTRAINTS_PER_CAPABILITY, + )); + } + for pair in constraints.windows(2) { + if pair[0].code() == pair[1].code() { + return Err(IdentityError::DuplicateElement { + resource: "capability constraints", + }); + } + if pair[0].code() > pair[1].code() { + return Err(IdentityError::NonCanonical); + } + } + + let minimum_epoch = constraints.iter().find_map(|constraint| match constraint { + CapabilityConstraint::AccountEpochAtLeast(epoch) => Some(*epoch), + _ => None, + }); + let maximum_epoch = constraints.iter().find_map(|constraint| match constraint { + CapabilityConstraint::AccountEpochAtMost(epoch) => Some(*epoch), + _ => None, + }); + if minimum_epoch + .zip(maximum_epoch) + .is_some_and(|(minimum, maximum)| minimum > maximum) + { + return Err(IdentityError::InvalidCapability { + reason: "minimum account epoch exceeds maximum account epoch", + }); + } + if constraints.iter().any(|constraint| { + matches!( + (constraint, expires_at), + (CapabilityConstraint::ValidFrom(valid_from), Some(expires_at)) + if *valid_from > expires_at + ) + }) { + return Err(IdentityError::InvalidCapability { + reason: "valid-from time exceeds expiration time", + }); + } + extensions.validate_critical(&[])?; + + Ok(Self { + protocol_version: ProtocolVersion::V1, + namespace, + action, + resource, + constraints, + delegation, + expires_at, + extensions, + }) + } + + /// Exact capability namespace. + pub const fn namespace(&self) -> &CapabilityNamespace { + &self.namespace + } + + /// Exact capability action. + pub const fn action(&self) -> &CapabilityAction { + &self.action + } + + /// Resource selector governed by this grant. + pub const fn resource(&self) -> &ResourceSelector { + &self.resource + } + + /// Canonically sorted conjunctive constraints. + pub fn constraints(&self) -> &[CapabilityConstraint] { + &self.constraints + } + + /// Remaining delegation permission. + pub const fn delegation(&self) -> DelegationPermission { + self.delegation + } + + /// Optional exclusive upper time bound represented by the grant. + pub const fn expires_at(&self) -> Option { + self.expires_at + } + + /// Preserved noncritical extension fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Derive the grant identifier from this exact canonical grant body. + pub fn capability_grant_id(&self) -> Result { + CapabilityGrantId::derive(self) + } +} + +impl<'de> Deserialize<'de> for CapabilityGrant { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + namespace: CapabilityNamespace, + action: CapabilityAction, + resource: ResourceSelector, + constraints: BoundedVec, + delegation: DelegationPermission, + expires_at: Option, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::from_sorted( + wire.namespace, + wire.action, + wire.resource, + wire.constraints.into_vec(), + wire.delegation, + wire.expires_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(CapabilityGrant, "capability grant bytes"); + +/// Account checkpoint context against which an authorization is interpreted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct AuthorizationContext { + account_id: AccountId, + epoch: Epoch, + checkpoint_id: CheckpointId, +} + +impl AuthorizationContext { + /// Construct an explicit account authorization context. + pub const fn new(account_id: AccountId, epoch: Epoch, checkpoint_id: CheckpointId) -> Self { + Self { + account_id, + epoch, + checkpoint_id, + } + } + + /// Account whose projected state supplies the authorization context. + pub const fn account_id(self) -> AccountId { + self.account_id + } + + /// Security-relevant account epoch at the referenced checkpoint. + pub const fn epoch(self) -> Epoch { + self.epoch + } + + /// Exact checkpoint supplying projected authorization state. + pub const fn checkpoint_id(self) -> CheckpointId { + self.checkpoint_id + } +} + +impl<'de> Deserialize<'de> for AuthorizationContext { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + account_id: AccountId, + epoch: Epoch, + checkpoint_id: CheckpointId, + } + + let wire = Wire::deserialize(deserializer)?; + Ok(Self::new(wire.account_id, wire.epoch, wire.checkpoint_id)) + } +} + +canonical_schema!(AuthorizationContext, "authorization context bytes"); + +/// Root authority and holder from which a delegation chain begins. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CapabilityRoot { + authorization_context: AuthorizationContext, + holder: DeviceId, + grant: CapabilityGrant, + extensions: Extensions, +} + +impl CapabilityRoot { + /// Construct a v1 capability root with understood critical extensions only. + pub fn new( + authorization_context: AuthorizationContext, + holder: DeviceId, + grant: CapabilityGrant, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + authorization_context, + holder, + grant, + extensions, + }) + } + + /// Account checkpoint context authorizing this root. + pub const fn authorization_context(&self) -> AuthorizationContext { + self.authorization_context + } + + /// Device initially holding this capability. + pub const fn holder(&self) -> DeviceId { + self.holder + } + + /// Root capability grant. + pub const fn grant(&self) -> &CapabilityGrant { + &self.grant + } + + /// Preserved noncritical extension fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for CapabilityRoot { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + authorization_context: AuthorizationContext, + holder: DeviceId, + grant: CapabilityGrant, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new( + wire.authorization_context, + wire.holder, + wire.grant, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(CapabilityRoot, "capability root bytes"); + +/// Canonical signed body for one semantic delegation edge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DelegationBody { + protocol_version: ProtocolVersion, + parent_grant_id: CapabilityGrantId, + child_grant: CapabilityGrant, + issuer: DeviceId, + subject: DeviceId, + authorization_context: AuthorizationContext, + issued_at: Timestamp, + nonce: [u8; 16], + extensions: Extensions, +} + +impl DelegationBody { + /// Construct one v1 delegation edge. + #[allow(clippy::too_many_arguments)] + pub fn new( + parent_grant_id: CapabilityGrantId, + child_grant: CapabilityGrant, + issuer: DeviceId, + subject: DeviceId, + authorization_context: AuthorizationContext, + issued_at: Timestamp, + nonce: [u8; 16], + extensions: Extensions, + ) -> Result { + if issuer == subject { + return Err(IdentityError::InvalidDelegation { + reason: "delegation issuer and subject are identical", + }); + } + if parent_grant_id == child_grant.capability_grant_id()? { + return Err(IdentityError::InvalidDelegation { + reason: "delegation child repeats its parent grant", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + parent_grant_id, + child_grant, + issuer, + subject, + authorization_context, + issued_at, + nonce, + extensions, + }) + } + + /// Grant identifier that this edge narrows. + pub const fn parent_grant_id(&self) -> CapabilityGrantId { + self.parent_grant_id + } + + /// Narrowed grant assigned by this edge. + pub const fn child_grant(&self) -> &CapabilityGrant { + &self.child_grant + } + + /// Device signing and issuing this edge. + pub const fn issuer(&self) -> DeviceId { + self.issuer + } + + /// Device receiving the child grant. + pub const fn subject(&self) -> DeviceId { + self.subject + } + + /// Account checkpoint context authorizing issuance. + pub const fn authorization_context(&self) -> AuthorizationContext { + self.authorization_context + } + + /// Explicit issuance time. + pub const fn issued_at(&self) -> Timestamp { + self.issued_at + } + + /// Caller-supplied uniqueness nonce. + pub const fn nonce(&self) -> &[u8; 16] { + &self.nonce + } + + /// Preserved noncritical extension fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Derive the delegation identifier from this exact canonical body. + pub fn delegation_id(&self) -> Result { + DelegationId::derive(self) + } +} + +impl<'de> Deserialize<'de> for DelegationBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + parent_grant_id: CapabilityGrantId, + child_grant: CapabilityGrant, + issuer: DeviceId, + subject: DeviceId, + authorization_context: AuthorizationContext, + issued_at: Timestamp, + nonce: [u8; 16], + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::new( + wire.parent_grant_id, + wire.child_grant, + wire.issuer, + wire.subject, + wire.authorization_context, + wire.issued_at, + wire.nonce, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(DelegationBody, "capability delegation body bytes"); + +/// One signed capability-delegation edge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedDelegation { + body: DelegationBody, + signature: ProtocolSignature, +} + +impl SignedDelegation { + /// Pair a validated delegation body with its protocol signature. + pub const fn new(body: DelegationBody, signature: ProtocolSignature) -> Self { + Self { body, signature } + } + + /// Signed delegation body. + pub const fn body(&self) -> &DelegationBody { + &self.body + } + + /// Protocol signature over the canonical body. + pub const fn signature(&self) -> ProtocolSignature { + self.signature + } + + /// Derive the delegation identifier from the signed body. + pub fn delegation_id(&self) -> Result { + self.body.delegation_id() + } +} + +canonical_schema!(SignedDelegation, "signed capability delegation bytes"); + +/// A bounded, semantic-order, same-account delegation chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DelegationChain { + root: CapabilityRoot, + links: Vec, +} + +impl DelegationChain { + /// Validate and construct a semantic-order chain of one to eight links. + pub fn new(root: CapabilityRoot, links: Vec) -> Result { + if links.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "delegation chain links", + }); + } + if links.len() > MAX_DELEGATION_DEPTH { + return Err(IdentityError::limit( + "delegation chain links", + links.len(), + MAX_DELEGATION_DEPTH, + )); + } + let chain = Self { root, links }; + chain.validate()?; + Ok(chain) + } + + /// Root authority and initial holder. + pub const fn root(&self) -> &CapabilityRoot { + &self.root + } + + /// Delegation edges in parent-to-child semantic order. + pub fn links(&self) -> &[SignedDelegation] { + &self.links + } + + /// Grant assigned by the final delegation edge. + pub fn leaf_grant(&self) -> &CapabilityGrant { + match self.links.last() { + Some(link) => link.body().child_grant(), + None => self.root.grant(), + } + } + + /// Device receiving the final delegation edge. + pub fn leaf_holder(&self) -> DeviceId { + match self.links.last() { + Some(link) => link.body().subject(), + None => self.root.holder(), + } + } + + fn validate(&self) -> Result<(), IdentityError> { + let root_account = self.root.authorization_context().account_id(); + let mut current_holder = self.root.holder(); + let mut current_grant = self.root.grant(); + let mut current_grant_id = current_grant.capability_grant_id()?; + + let mut seen_devices = Vec::with_capacity(self.links.len().saturating_add(1)); + let mut seen_grants = Vec::with_capacity(self.links.len().saturating_add(1)); + let mut seen_delegations = Vec::with_capacity(self.links.len()); + seen_devices.push(current_holder); + seen_grants.push(current_grant_id); + + for link in &self.links { + let body = link.body(); + if body.authorization_context().account_id() != root_account { + return Err(IdentityError::InvalidDelegation { + reason: "delegation chain crosses account contexts", + }); + } + if body.issuer() != current_holder { + return Err(IdentityError::InvalidDelegation { + reason: "delegation issuer does not hold the parent grant", + }); + } + if body.parent_grant_id() != current_grant_id { + return Err(IdentityError::InvalidDelegation { + reason: "delegation parent grant is out of semantic order", + }); + } + if seen_devices.contains(&body.subject()) { + return Err(IdentityError::InvalidDelegation { + reason: "delegation chain contains a device cycle", + }); + } + + let child_grant = body.child_grant(); + validate_narrowing(current_grant, child_grant)?; + let child_grant_id = child_grant.capability_grant_id()?; + if seen_grants.contains(&child_grant_id) { + return Err(IdentityError::InvalidDelegation { + reason: "delegation chain repeats a grant identifier", + }); + } + let delegation_id = link.delegation_id()?; + if seen_delegations.contains(&delegation_id) { + return Err(IdentityError::InvalidDelegation { + reason: "delegation chain repeats a delegation identifier", + }); + } + + seen_devices.push(body.subject()); + seen_grants.push(child_grant_id); + seen_delegations.push(delegation_id); + current_holder = body.subject(); + current_grant = child_grant; + current_grant_id = child_grant_id; + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for DelegationChain { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + root: CapabilityRoot, + links: BoundedVec, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.root, wire.links.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(DelegationChain, "delegation chain bytes"); + +fn validate_narrowing( + parent: &CapabilityGrant, + child: &CapabilityGrant, +) -> Result<(), IdentityError> { + if parent.namespace() != child.namespace() || parent.action() != child.action() { + return Err(IdentityError::InvalidDelegation { + reason: "delegation changes capability namespace or action", + }); + } + + let mut strict = validate_resource_narrowing(parent.resource(), child.resource())?; + strict |= validate_constraint_narrowing(parent.constraints(), child.constraints())?; + strict |= validate_expiration_narrowing(parent.expires_at(), child.expires_at())?; + strict |= validate_permission_narrowing(parent.delegation(), child.delegation())?; + + if !strict { + return Err(IdentityError::InvalidDelegation { + reason: "delegation child does not strictly narrow its parent", + }); + } + Ok(()) +} + +fn validate_resource_narrowing( + parent: &ResourceSelector, + child: &ResourceSelector, +) -> Result { + match (parent, child) { + (ResourceSelector::Exact(parent), ResourceSelector::Exact(child)) if parent == child => { + Ok(false) + } + (ResourceSelector::Prefix(parent), ResourceSelector::Prefix(child)) + if child.starts_with(parent) => + { + Ok(parent != child) + } + (ResourceSelector::Prefix(parent), ResourceSelector::Exact(child)) + if child.starts_with(parent) => + { + Ok(true) + } + _ => Err(IdentityError::InvalidDelegation { + reason: "delegation broadens its resource selector", + }), + } +} + +fn validate_constraint_narrowing( + parent: &[CapabilityConstraint], + child: &[CapabilityConstraint], +) -> Result { + let mut strict = false; + for code in 1..=3 { + let parent_value = parent + .iter() + .find(|constraint| constraint.code() == code) + .map(|constraint| constraint.value()); + let child_value = child + .iter() + .find(|constraint| constraint.code() == code) + .map(|constraint| constraint.value()); + + match (code, parent_value, child_value) { + (_, Some(_), None) => { + return Err(IdentityError::InvalidDelegation { + reason: "delegation removes a parent constraint", + }); + } + (1 | 3, Some(parent_value), Some(child_value)) => { + if child_value < parent_value { + return Err(IdentityError::InvalidDelegation { + reason: "delegation weakens a lower-bound constraint", + }); + } + strict |= child_value > parent_value; + } + (2, Some(parent_value), Some(child_value)) => { + if child_value > parent_value { + return Err(IdentityError::InvalidDelegation { + reason: "delegation weakens an upper-bound constraint", + }); + } + strict |= child_value < parent_value; + } + (_, None, Some(_)) => strict = true, + (_, None, None) => {} + _ => { + return Err(IdentityError::InvalidDelegation { + reason: "delegation has an invalid constraint relationship", + }); + } + } + } + Ok(strict) +} + +fn validate_expiration_narrowing( + parent: Option, + child: Option, +) -> Result { + match (parent, child) { + (None, None) => Ok(false), + (None, Some(_)) => Ok(true), + (Some(_), None) => Err(IdentityError::InvalidDelegation { + reason: "delegation removes parent expiration", + }), + (Some(parent), Some(child)) if child <= parent => Ok(child < parent), + (Some(_), Some(_)) => Err(IdentityError::InvalidDelegation { + reason: "delegation extends parent expiration", + }), + } +} + +fn validate_permission_narrowing( + parent: DelegationPermission, + child: DelegationPermission, +) -> Result { + match (parent, child) { + (DelegationPermission::NotDelegable, _) => Err(IdentityError::InvalidDelegation { + reason: "parent grant is not delegable", + }), + (DelegationPermission::Delegable { .. }, DelegationPermission::NotDelegable) => Ok(true), + ( + DelegationPermission::Delegable { remaining: parent }, + DelegationPermission::Delegable { remaining: child }, + ) if child.get() < parent.get() => Ok(true), + (DelegationPermission::Delegable { .. }, DelegationPermission::Delegable { .. }) => { + Err(IdentityError::InvalidDelegation { + reason: "delegation remaining depth does not decrease", + }) + } + } +} diff --git a/protocols/krikos-identity/src/capability_verifier.rs b/protocols/krikos-identity/src/capability_verifier.rs new file mode 100644 index 00000000000..0b4bc2b16a0 --- /dev/null +++ b/protocols/krikos-identity/src/capability_verifier.rs @@ -0,0 +1,905 @@ +//! Deterministic, default-deny capability and delegation evaluation. + +use crate::{ + AccountId, ApplicationId, AuthorizationContext, CapabilityAction, CapabilityConstraint, + CapabilityGrant, CapabilityGrantId, CapabilityNamespace, CheckpointId, DelegationChain, + DelegationId, DelegationPermission, DeviceId, Epoch, ResourcePath, ResourceSelector, + SignedDelegation, Timestamp, + limits::{MAX_CAPABILITIES_PER_DEVICE, MAX_DELEGATION_DEPTH}, +}; + +/// Current lifecycle status of a device in the supplied authorization projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CapabilityDeviceStatus { + /// The device does not exist in the supplied projection. + Unknown, + /// The device is currently authorized to exercise capabilities. + Active, + /// The device is temporarily unable to exercise capabilities. + Suspended, + /// The device is permanently unable to exercise capabilities. + Revoked, +} + +/// Result of checking a delegation signature at the caller-owned cryptographic boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DelegationSignatureStatus { + /// The signature was cryptographically verified with the issuer key valid for its context. + Verified, + /// Cryptographic verification completed and rejected the signature. + Invalid, + /// Verification could not be performed with the available keys or algorithms. + Unavailable, +} + +/// Cryptographic verification seam required for every delegated capability link. +/// +/// The capability evaluator intentionally owns no key lookup, clock, or mutable state. An +/// implementation must resolve the issuer key that was valid at the delegation body's explicit +/// authorization context and verify the signature over that exact canonical body. Returning +/// [`DelegationSignatureStatus::Unavailable`] is fail-closed. +pub trait DelegationSignatureVerifier { + /// Verify one signed delegation without inferring authority from the signature alone. + fn verify_delegation(&self, delegation: &SignedDelegation) -> DelegationSignatureStatus; +} + +/// Read-only authorization facts consumed by capability evaluation. +/// +/// Implementations are trusted projections of already-validated account history. They must not +/// perform network or wall-clock access. Returned root-grant slices are peer-controlled protocol +/// state and are therefore re-bounded by the evaluator. +pub trait CapabilityStateView { + /// Exact current account/checkpoint/epoch basis represented by this view. + fn authorization_context(&self) -> AuthorizationContext; + + /// Current status of the named device, or [`CapabilityDeviceStatus::Unknown`]. + fn device_status(&self, device_id: DeviceId) -> CapabilityDeviceStatus; + + /// Currently installed root grants for the named device. + fn root_grants(&self, holder: DeviceId) -> &[CapabilityGrant]; + + /// Whether account state has revoked this grant identifier. + fn is_grant_revoked(&self, grant_id: CapabilityGrantId) -> bool; + + /// Whether account state has revoked this exact delegation link. + fn is_delegation_revoked(&self, delegation_id: DelegationId) -> bool; + + /// Whether a historical authorization context is an authenticated context on the accepted + /// lineage represented by this view. + fn recognizes_authorization_context(&self, context: AuthorizationContext) -> bool; + + /// Whether `ancestor` and `descendant` have authenticated predecessor lineage in this view. + /// Equal contexts must return `true`. + fn authorization_context_precedes_or_equals( + &self, + ancestor: AuthorizationContext, + descendant: AuthorizationContext, + ) -> bool; + + /// Authenticated wall-clock time carried by or proven for this historical context. + fn authorization_context_timestamp(&self, context: AuthorizationContext) -> Option; + + /// Historical lifecycle status authenticated at an accepted authorization context. + fn device_status_at( + &self, + device_id: DeviceId, + context: AuthorizationContext, + ) -> CapabilityDeviceStatus; + + /// Whether the device held this exact grant at the authenticated historical context. + fn held_grant_at( + &self, + holder: DeviceId, + grant_id: CapabilityGrantId, + context: AuthorizationContext, + ) -> bool; +} + +/// One bounded capability request evaluated at an explicit authorization basis and time. +/// +/// Namespace, action, and resource bounds are enforced by their validated schema types before a +/// request can exist. The application identifier is retained in the decision for audit binding; +/// v1 capability grants select applications through their exact namespace rather than by digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityRequest { + authorization_context: AuthorizationContext, + application_id: ApplicationId, + device_id: DeviceId, + namespace: CapabilityNamespace, + action: CapabilityAction, + resource: ResourcePath, + evaluated_at: Timestamp, +} + +impl CapabilityRequest { + /// Construct a request entirely from already-bounded domain values. + #[allow(clippy::too_many_arguments)] + pub const fn new( + authorization_context: AuthorizationContext, + application_id: ApplicationId, + device_id: DeviceId, + namespace: CapabilityNamespace, + action: CapabilityAction, + resource: ResourcePath, + evaluated_at: Timestamp, + ) -> Self { + Self { + authorization_context, + application_id, + device_id, + namespace, + action, + resource, + evaluated_at, + } + } + + /// Explicit account/checkpoint/epoch basis requested by the caller. + pub const fn authorization_context(&self) -> AuthorizationContext { + self.authorization_context + } + + /// Account named by the request basis. + pub const fn account_id(&self) -> AccountId { + self.authorization_context.account_id() + } + + /// Application interpreting the authorized operation. + pub const fn application_id(&self) -> ApplicationId { + self.application_id + } + + /// Device attempting to exercise authority. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Exact requested capability namespace. + pub const fn namespace(&self) -> &CapabilityNamespace { + &self.namespace + } + + /// Exact requested capability action. + pub const fn action(&self) -> &CapabilityAction { + &self.action + } + + /// Requested structured resource path. + pub const fn resource(&self) -> &ResourcePath { + &self.resource + } + + /// Explicit evaluation time supplied by the caller's effect boundary. + pub const fn evaluated_at(&self) -> Timestamp { + self.evaluated_at + } +} + +/// Evidence path by which a request claims a capability. +#[derive(Debug, Clone, Copy)] +pub enum CapabilityProof<'a> { + /// Match a root grant installed directly for the requesting device. + Direct, + /// Match the leaf of this complete, parent-to-child delegation chain. + Delegated(&'a DelegationChain), +} + +/// Stable reason that a capability request was denied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CapabilityDenialReason { + /// The request and supplied state view name different accounts. + AccountMismatch, + /// The request epoch differs from the supplied state view's exact epoch. + EpochMismatch, + /// The request checkpoint differs from the supplied state view's exact checkpoint. + CheckpointMismatch, + /// No such device exists in the supplied state view. + UnknownDevice, + /// The requesting device is suspended. + DeviceSuspended, + /// The requesting device is terminally revoked. + DeviceRevoked, + /// A state-view collection exceeded its protocol maximum. + StateViewLimitExceeded, + /// No root grant was installed for the requested proof. + NoMatchingGrant, + /// Candidate grants did not contain the exact requested namespace. + NamespaceNotGranted, + /// Candidate grants did not contain the exact requested action. + ActionNotGranted, + /// Candidate selectors did not contain the complete requested resource path. + ResourceNotGranted, + /// At least one typed conjunctive grant constraint was not satisfied. + ConstraintUnsatisfied, + /// The otherwise matching grant reached its exclusive expiration bound. + GrantExpired, + /// The otherwise matching leaf or direct grant has been revoked. + GrantRevoked, + /// A parent grant in a delegation chain has been revoked. + ParentGrantRevoked, + /// A delegation link has been revoked. + DelegationRevoked, + /// A chain was malformed, cyclic, out of order, or did not strictly narrow authority. + InvalidDelegationChain, + /// A chain context is not an authenticated accepted context in the supplied state view. + UnrecognizedAuthorizationContext, + /// A delegation was issued after the request's explicit evaluation time. + DelegationNotYetValid, + /// A later link names an authorization context preceding its parent link's context. + AuthorizationContextRollback, + /// The issuer was not active at the exact context claimed by the delegation body. + IssuerNotActiveAtIssuance, + /// The issuer did not hold the exact parent grant at the claimed issuance context. + ParentGrantNotHeldAtIssuance, + /// The parent grant's epoch, valid-from, or expiry rules failed at issuance. + ParentGrantInvalidAtIssuance, + /// The root holder did not hold the root grant at the root's historical context. + RootGrantNotHeldAtContext, + /// The root holder was not active at the root's historical context. + RootHolderNotActiveAtContext, + /// The root context has no authenticated timestamp in the supplied state view. + RootContextTimestampUnavailable, + /// The root grant's epoch, valid-from, or expiry rules failed at its historical context. + RootGrantInvalidAtContext, + /// A delegation context has no authenticated timestamp in the supplied state view. + DelegationContextTimestampUnavailable, + /// A delegation claims issuance before its authenticated authorization-context timestamp. + DelegationIssuedBeforeContext, + /// Delegation issuance time moved backward from the root context or preceding link. + DelegationIssuanceRollback, + /// Cryptographic verification rejected a delegation signature. + InvalidDelegationSignature, + /// Required delegation signature verification was unavailable. + SignatureVerificationUnavailable, + /// A content identifier could not be derived from validated evidence. + InvalidCapabilityEvidence, +} + +/// Default-deny result bound to the request's exact checkpoint and epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapabilityDecision { + authorization_context: AuthorizationContext, + application_id: ApplicationId, + device_id: DeviceId, + outcome: CapabilityOutcome, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CapabilityOutcome { + Allowed { + grant_id: CapabilityGrantId, + delegation_id: Option, + }, + Denied(CapabilityDenialReason), +} + +impl CapabilityDecision { + fn allowed( + request: &CapabilityRequest, + grant_id: CapabilityGrantId, + delegation_id: Option, + ) -> Self { + Self { + authorization_context: request.authorization_context(), + application_id: request.application_id(), + device_id: request.device_id(), + outcome: CapabilityOutcome::Allowed { + grant_id, + delegation_id, + }, + } + } + + fn denied(request: &CapabilityRequest, reason: CapabilityDenialReason) -> Self { + Self { + authorization_context: request.authorization_context(), + application_id: request.application_id(), + device_id: request.device_id(), + outcome: CapabilityOutcome::Denied(reason), + } + } + + /// Whether the request was authorized. + pub const fn is_allowed(self) -> bool { + matches!(self.outcome, CapabilityOutcome::Allowed { .. }) + } + + /// Denial reason, or `None` for an authorized request. + pub const fn denial_reason(self) -> Option { + match self.outcome { + CapabilityOutcome::Allowed { .. } => None, + CapabilityOutcome::Denied(reason) => Some(reason), + } + } + + /// Exact request checkpoint on which this decision is based. + pub const fn checkpoint_id(self) -> CheckpointId { + self.authorization_context.checkpoint_id() + } + + /// Exact request epoch on which this decision is based. + pub const fn epoch(self) -> Epoch { + self.authorization_context.epoch() + } + + /// Account named by the decision basis. + pub const fn account_id(self) -> AccountId { + self.authorization_context.account_id() + } + + /// Application retained as part of the decision's audit context. + pub const fn application_id(self) -> ApplicationId { + self.application_id + } + + /// Device whose request produced this decision. + pub const fn device_id(self) -> DeviceId { + self.device_id + } + + /// Grant identifier authorizing the request, or `None` when denied. + pub const fn grant_id(self) -> Option { + match self.outcome { + CapabilityOutcome::Allowed { grant_id, .. } => Some(grant_id), + CapabilityOutcome::Denied(_) => None, + } + } + + /// Final delegation identifier, or `None` for direct grants and denials. + pub const fn delegation_id(self) -> Option { + match self.outcome { + CapabilityOutcome::Allowed { delegation_id, .. } => delegation_id, + CapabilityOutcome::Denied(_) => None, + } + } +} + +/// Evaluate one request deterministically against an immutable state view. +/// +/// The evaluator is default-deny and performs no clock, storage, key lookup, or network access. +/// Delegated proofs require every signature verifier result to be explicitly `Verified`. +pub fn evaluate_capability( + request: &CapabilityRequest, + proof: CapabilityProof<'_>, + state: &impl CapabilityStateView, + signatures: &impl DelegationSignatureVerifier, +) -> CapabilityDecision { + if let Some(reason) = validate_current_context(request, state) { + return CapabilityDecision::denied(request, reason); + } + if let Some(reason) = validate_device_status(state.device_status(request.device_id())) { + return CapabilityDecision::denied(request, reason); + } + + match proof { + CapabilityProof::Direct => evaluate_direct(request, state), + CapabilityProof::Delegated(chain) => evaluate_delegated(request, chain, state, signatures), + } +} + +fn validate_current_context( + request: &CapabilityRequest, + state: &impl CapabilityStateView, +) -> Option { + let requested = request.authorization_context(); + let current = state.authorization_context(); + if requested.account_id() != current.account_id() { + return Some(CapabilityDenialReason::AccountMismatch); + } + if requested.epoch() != current.epoch() { + return Some(CapabilityDenialReason::EpochMismatch); + } + if requested.checkpoint_id() != current.checkpoint_id() { + return Some(CapabilityDenialReason::CheckpointMismatch); + } + None +} + +fn validate_device_status(status: CapabilityDeviceStatus) -> Option { + match status { + CapabilityDeviceStatus::Active => None, + CapabilityDeviceStatus::Unknown => Some(CapabilityDenialReason::UnknownDevice), + CapabilityDeviceStatus::Suspended => Some(CapabilityDenialReason::DeviceSuspended), + CapabilityDeviceStatus::Revoked => Some(CapabilityDenialReason::DeviceRevoked), + } +} + +fn evaluate_direct( + request: &CapabilityRequest, + state: &impl CapabilityStateView, +) -> CapabilityDecision { + let grants = state.root_grants(request.device_id()); + if grants.len() > MAX_CAPABILITIES_PER_DEVICE { + return CapabilityDecision::denied(request, CapabilityDenialReason::StateViewLimitExceeded); + } + + let mut best_denial = CapabilityDenialReason::NoMatchingGrant; + for grant in grants { + let grant_id = match grant.capability_grant_id() { + Ok(grant_id) => grant_id, + Err(_) => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidCapabilityEvidence, + ); + } + }; + let mismatch = match grant_mismatch(grant, request) { + Some(reason) => reason, + None if state.is_grant_revoked(grant_id) => CapabilityDenialReason::GrantRevoked, + None => return CapabilityDecision::allowed(request, grant_id, None), + }; + if mismatch_priority(mismatch) > mismatch_priority(best_denial) { + best_denial = mismatch; + } + } + CapabilityDecision::denied(request, best_denial) +} + +fn evaluate_delegated( + request: &CapabilityRequest, + chain: &DelegationChain, + state: &impl CapabilityStateView, + signatures: &impl DelegationSignatureVerifier, +) -> CapabilityDecision { + let links = chain.links(); + if links.is_empty() || links.len() > MAX_DELEGATION_DEPTH { + return CapabilityDecision::denied(request, CapabilityDenialReason::InvalidDelegationChain); + } + + let root = chain.root(); + if !context_is_usable(root.authorization_context(), request, state) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::UnrecognizedAuthorizationContext, + ); + } + if let Some(reason) = validate_device_status(state.device_status(root.holder())) { + return CapabilityDecision::denied(request, reason); + } + + let root_grants = state.root_grants(root.holder()); + if root_grants.len() > MAX_CAPABILITIES_PER_DEVICE { + return CapabilityDecision::denied(request, CapabilityDenialReason::StateViewLimitExceeded); + } + let root_grant_id = match root.grant().capability_grant_id() { + Ok(grant_id) => grant_id, + Err(_) => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidCapabilityEvidence, + ); + } + }; + let mut root_is_installed = false; + for grant in root_grants { + let installed_grant_id = match grant.capability_grant_id() { + Ok(grant_id) => grant_id, + Err(_) => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidCapabilityEvidence, + ); + } + }; + root_is_installed |= installed_grant_id == root_grant_id; + } + if !root_is_installed { + return CapabilityDecision::denied(request, CapabilityDenialReason::NoMatchingGrant); + } + if state.is_grant_revoked(root_grant_id) { + return CapabilityDecision::denied(request, CapabilityDenialReason::ParentGrantRevoked); + } + if !state.held_grant_at(root.holder(), root_grant_id, root.authorization_context()) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::RootGrantNotHeldAtContext, + ); + } + if state.device_status_at(root.holder(), root.authorization_context()) + != CapabilityDeviceStatus::Active + { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::RootHolderNotActiveAtContext, + ); + } + let root_context_time = + match state.authorization_context_timestamp(root.authorization_context()) { + Some(timestamp) => timestamp, + None => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::RootContextTimestampUnavailable, + ); + } + }; + if !grant_is_valid_at( + root.grant(), + root.authorization_context().epoch(), + root_context_time, + ) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::RootGrantInvalidAtContext, + ); + } + + let mut current_holder = root.holder(); + let mut current_grant = root.grant(); + let mut current_grant_id = root_grant_id; + let mut previous_context = root.authorization_context(); + let mut previous_issued_at = root_context_time; + let mut last_delegation_id = None; + let mut seen_devices = Vec::with_capacity(links.len().saturating_add(1)); + let mut seen_grants = Vec::with_capacity(links.len().saturating_add(1)); + let mut seen_delegations = Vec::with_capacity(links.len()); + seen_devices.push(current_holder); + seen_grants.push(current_grant_id); + + for (index, link) in links.iter().enumerate() { + let body = link.body(); + if body.issuer() != current_holder || body.parent_grant_id() != current_grant_id { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidDelegationChain, + ); + } + if !context_is_usable(body.authorization_context(), request, state) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::UnrecognizedAuthorizationContext, + ); + } + if !contexts_are_monotonic(previous_context, body.authorization_context()) + || !state.authorization_context_precedes_or_equals( + previous_context, + body.authorization_context(), + ) + { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::AuthorizationContextRollback, + ); + } + let context_timestamp = + match state.authorization_context_timestamp(body.authorization_context()) { + Some(timestamp) => timestamp, + None => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::DelegationContextTimestampUnavailable, + ); + } + }; + if context_timestamp > body.issued_at() { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::DelegationIssuedBeforeContext, + ); + } + if previous_issued_at > body.issued_at() { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::DelegationIssuanceRollback, + ); + } + if state.device_status_at(body.issuer(), body.authorization_context()) + != CapabilityDeviceStatus::Active + { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::IssuerNotActiveAtIssuance, + ); + } + if !state.held_grant_at( + body.issuer(), + current_grant_id, + body.authorization_context(), + ) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::ParentGrantNotHeldAtIssuance, + ); + } + if !grant_is_valid_at( + current_grant, + body.authorization_context().epoch(), + body.issued_at(), + ) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::ParentGrantInvalidAtIssuance, + ); + } + if state.is_grant_revoked(current_grant_id) { + return CapabilityDecision::denied(request, CapabilityDenialReason::ParentGrantRevoked); + } + if seen_devices.contains(&body.subject()) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidDelegationChain, + ); + } + if let Some(reason) = validate_device_status(state.device_status(body.subject())) { + return CapabilityDecision::denied(request, reason); + } + if !is_strict_narrowing(current_grant, body.child_grant()) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidDelegationChain, + ); + } + + let child_grant_id = match body.child_grant().capability_grant_id() { + Ok(grant_id) => grant_id, + Err(_) => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidCapabilityEvidence, + ); + } + }; + let delegation_id = match link.delegation_id() { + Ok(delegation_id) => delegation_id, + Err(_) => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidCapabilityEvidence, + ); + } + }; + if seen_grants.contains(&child_grant_id) || seen_delegations.contains(&delegation_id) { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidDelegationChain, + ); + } + if state.is_delegation_revoked(delegation_id) { + return CapabilityDecision::denied(request, CapabilityDenialReason::DelegationRevoked); + } + match signatures.verify_delegation(link) { + DelegationSignatureStatus::Verified => {} + DelegationSignatureStatus::Invalid => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::InvalidDelegationSignature, + ); + } + DelegationSignatureStatus::Unavailable => { + return CapabilityDecision::denied( + request, + CapabilityDenialReason::SignatureVerificationUnavailable, + ); + } + } + + let is_leaf = index.checked_add(1) == Some(links.len()); + if state.is_grant_revoked(child_grant_id) { + let reason = if is_leaf { + CapabilityDenialReason::GrantRevoked + } else { + CapabilityDenialReason::ParentGrantRevoked + }; + return CapabilityDecision::denied(request, reason); + } + + seen_devices.push(body.subject()); + seen_grants.push(child_grant_id); + seen_delegations.push(delegation_id); + current_holder = body.subject(); + current_grant = body.child_grant(); + current_grant_id = child_grant_id; + previous_context = body.authorization_context(); + previous_issued_at = body.issued_at(); + last_delegation_id = Some(delegation_id); + } + + if previous_issued_at > request.evaluated_at() { + return CapabilityDecision::denied(request, CapabilityDenialReason::DelegationNotYetValid); + } + if current_holder != request.device_id() || chain.leaf_holder() != request.device_id() { + return CapabilityDecision::denied(request, CapabilityDenialReason::InvalidDelegationChain); + } + if let Some(reason) = grant_mismatch(current_grant, request) { + return CapabilityDecision::denied(request, reason); + } + CapabilityDecision::allowed(request, current_grant_id, last_delegation_id) +} + +fn context_is_usable( + context: AuthorizationContext, + request: &CapabilityRequest, + state: &impl CapabilityStateView, +) -> bool { + context.account_id() == request.account_id() + && contexts_are_monotonic(context, request.authorization_context()) + && state.recognizes_authorization_context(context) + && state.authorization_context_precedes_or_equals(context, request.authorization_context()) +} + +fn contexts_are_monotonic( + ancestor: AuthorizationContext, + descendant: AuthorizationContext, +) -> bool { + if ancestor.account_id() != descendant.account_id() || ancestor.epoch() > descendant.epoch() { + return false; + } + ancestor.epoch() <= descendant.epoch() +} + +fn grant_mismatch( + grant: &CapabilityGrant, + request: &CapabilityRequest, +) -> Option { + if grant.namespace() != request.namespace() { + return Some(CapabilityDenialReason::NamespaceNotGranted); + } + if grant.action() != request.action() { + return Some(CapabilityDenialReason::ActionNotGranted); + } + if !selector_contains(grant.resource(), request.resource()) { + return Some(CapabilityDenialReason::ResourceNotGranted); + } + if !constraints_hold( + grant.constraints(), + request.authorization_context().epoch(), + request.evaluated_at(), + ) { + return Some(CapabilityDenialReason::ConstraintUnsatisfied); + } + if grant + .expires_at() + .is_some_and(|expires_at| request.evaluated_at() >= expires_at) + { + return Some(CapabilityDenialReason::GrantExpired); + } + None +} + +fn selector_contains(selector: &ResourceSelector, resource: &ResourcePath) -> bool { + match selector { + ResourceSelector::Exact(expected) => expected == resource, + ResourceSelector::Prefix(prefix) => path_starts_with(resource, prefix), + } +} + +fn path_starts_with(path: &ResourcePath, prefix: &ResourcePath) -> bool { + path.segments().len() >= prefix.segments().len() + && path + .segments() + .iter() + .zip(prefix.segments()) + .all(|(segment, prefix_segment)| segment == prefix_segment) +} + +fn constraints_hold( + constraints: &[CapabilityConstraint], + epoch: Epoch, + evaluated_at: Timestamp, +) -> bool { + constraints.iter().all(|constraint| match constraint { + CapabilityConstraint::AccountEpochAtLeast(minimum) => epoch >= *minimum, + CapabilityConstraint::AccountEpochAtMost(maximum) => epoch <= *maximum, + CapabilityConstraint::ValidFrom(valid_from) => evaluated_at >= *valid_from, + }) +} + +fn grant_is_valid_at(grant: &CapabilityGrant, epoch: Epoch, evaluated_at: Timestamp) -> bool { + constraints_hold(grant.constraints(), epoch, evaluated_at) + && grant + .expires_at() + .is_none_or(|expires_at| evaluated_at < expires_at) +} + +fn is_strict_narrowing(parent: &CapabilityGrant, child: &CapabilityGrant) -> bool { + if parent.namespace() != child.namespace() || parent.action() != child.action() { + return false; + } + + let resource_strict = match resource_narrowing(parent.resource(), child.resource()) { + Some(strict) => strict, + None => return false, + }; + let constraint_strict = match constraint_narrowing(parent.constraints(), child.constraints()) { + Some(strict) => strict, + None => return false, + }; + let expiration_strict = match expiration_narrowing(parent.expires_at(), child.expires_at()) { + Some(strict) => strict, + None => return false, + }; + let permission_strict = match permission_narrowing(parent.delegation(), child.delegation()) { + Some(strict) => strict, + None => return false, + }; + + resource_strict || constraint_strict || expiration_strict || permission_strict +} + +fn resource_narrowing(parent: &ResourceSelector, child: &ResourceSelector) -> Option { + match (parent, child) { + (ResourceSelector::Exact(parent), ResourceSelector::Exact(child)) if parent == child => { + Some(false) + } + (ResourceSelector::Prefix(parent), ResourceSelector::Prefix(child)) + if path_starts_with(child, parent) => + { + Some(parent != child) + } + (ResourceSelector::Prefix(parent), ResourceSelector::Exact(child)) + if path_starts_with(child, parent) => + { + Some(true) + } + _ => None, + } +} + +fn constraint_narrowing( + parent: &[CapabilityConstraint], + child: &[CapabilityConstraint], +) -> Option { + let mut strict = false; + for code in 1..=3 { + let parent_value = constraint_value(parent, code); + let child_value = constraint_value(child, code); + match (code, parent_value, child_value) { + (_, Some(_), None) => return None, + (1 | 3, Some(parent_value), Some(child_value)) if child_value >= parent_value => { + strict |= child_value > parent_value; + } + (2, Some(parent_value), Some(child_value)) if child_value <= parent_value => { + strict |= child_value < parent_value; + } + (_, None, Some(_)) => strict = true, + (_, None, None) => {} + _ => return None, + } + } + Some(strict) +} + +fn constraint_value(constraints: &[CapabilityConstraint], code: u16) -> Option { + constraints.iter().find_map(|constraint| match constraint { + CapabilityConstraint::AccountEpochAtLeast(epoch) if code == 1 => Some(epoch.get()), + CapabilityConstraint::AccountEpochAtMost(epoch) if code == 2 => Some(epoch.get()), + CapabilityConstraint::ValidFrom(timestamp) if code == 3 => Some(timestamp.as_unix_millis()), + _ => None, + }) +} + +fn expiration_narrowing(parent: Option, child: Option) -> Option { + match (parent, child) { + (None, None) => Some(false), + (None, Some(_)) => Some(true), + (Some(_), None) => None, + (Some(parent), Some(child)) if child <= parent => Some(child < parent), + (Some(_), Some(_)) => None, + } +} + +fn permission_narrowing(parent: DelegationPermission, child: DelegationPermission) -> Option { + match (parent.remaining(), child.remaining()) { + (None, _) => None, + (Some(_), None) => Some(true), + (Some(parent), Some(child)) if child.get() < parent.get() => Some(true), + (Some(_), Some(_)) => None, + } +} + +const fn mismatch_priority(reason: CapabilityDenialReason) -> u8 { + match reason { + CapabilityDenialReason::NoMatchingGrant => 0, + CapabilityDenialReason::NamespaceNotGranted => 1, + CapabilityDenialReason::ActionNotGranted => 2, + CapabilityDenialReason::ResourceNotGranted => 3, + CapabilityDenialReason::ConstraintUnsatisfied => 4, + CapabilityDenialReason::GrantExpired => 5, + CapabilityDenialReason::GrantRevoked => 6, + _ => 0, + } +} diff --git a/protocols/krikos-identity/src/checkpoint.rs b/protocols/krikos-identity/src/checkpoint.rs new file mode 100644 index 00000000000..528809ae9bd --- /dev/null +++ b/protocols/krikos-identity/src/checkpoint.rs @@ -0,0 +1,1847 @@ +//! Canonical checkpoint and transparency evidence schemas. + +use std::fmt; + +use krikos_base::{PublicKey, Signature}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeTuple}; + +use crate::{ + AccountId, AccountOperation, AuthorizedEvent, CanonicalWire, CheckpointId, ControlPolicyId, + ControllerApprovals, CryptoStateId, Digest, Epoch, EventAuthorizationId, EventId, Extensions, + IdentityError, ProposalId, ProtocolSignature, ProtocolVersion, ProviderDescriptor, ProviderId, + ProviderKeyVersion, ProviderLogId, ProviderPolicyId, RecoveryPolicyId, Sequence, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_MERKLE_LOG_LEAVES, MAX_MERKLE_PROOF_HASHES, MAX_TRANSPARENCY_PROVIDERS}, + merkle::{ + MerkleConsistencyProof, MerkleInclusionProof, MerkleSet, MerkleSetKey, MerkleSetLeaf, + empty_merkle_root, + }, + schema::BoundedVec, + types::{HashDomain, hash_bytes}, +}; + +/// Frozen Merkle-set type tag for the cache-free account projection metadata leaf. +pub const CHECKPOINT_STATE_METADATA_TYPE_TAG: u16 = 1; +/// Frozen Merkle-set type tag for projected controller records in the complete state root. +pub const CHECKPOINT_STATE_CONTROLLER_TYPE_TAG: u16 = 2; +/// Frozen Merkle-set type tag for projected device records in the complete state root. +pub const CHECKPOINT_STATE_DEVICE_TYPE_TAG: u16 = 3; +/// Frozen Merkle-set type tag for active devices in the authorized-device root. +pub const CHECKPOINT_AUTHORIZED_DEVICE_TYPE_TAG: u16 = 4; +/// Frozen Merkle-set type tag for revoked devices in the tombstone root. +pub const CHECKPOINT_REVOKED_DEVICE_TYPE_TAG: u16 = 5; + +const PROVIDER_HEAD_SIGNATURE_DOMAIN: &[u8] = b"KRIKOS-ID/provider-head-signature/v1"; + +macro_rules! canonical_schema { + ($name:ty, $resource:literal) => { + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +/// Subject committed by one transparency-provider log entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderLogSubject { + /// A fully authorized account checkpoint. + Checkpoint(CheckpointId), + /// A threshold-approved proposal intent used to start a policy delay. + EventIntent(ProposalId), +} + +impl ProviderLogSubject { + /// Stable v1 subject codepoint. + pub const fn code(self) -> u16 { + match self { + Self::Checkpoint(_) => 1, + Self::EventIntent(_) => 2, + } + } +} + +impl Serialize for ProviderLogSubject { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut tuple = serializer.serialize_tuple(2)?; + tuple.serialize_element(&self.code())?; + match self { + Self::Checkpoint(id) => tuple.serialize_element(id)?, + Self::EventIntent(id) => tuple.serialize_element(id)?, + } + tuple.end() + } +} + +impl<'de> Deserialize<'de> for ProviderLogSubject { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = ProviderLogSubject; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a v1 provider log subject") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => Ok(ProviderLogSubject::Checkpoint( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )), + 2 => Ok(ProviderLogSubject::EventIntent( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "provider log subject", + code: unsupported, + })), + } + } + } + + deserializer.deserialize_tuple(2, Visitor) + } +} + +/// Canonical body appended to a transparency provider's log. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderLogEntryBody { + protocol_version: ProtocolVersion, + provider_id: ProviderId, + log_id: ProviderLogId, + account_id: AccountId, + subject: ProviderLogSubject, + observed_at: Timestamp, + extensions: Extensions, +} + +impl ProviderLogEntryBody { + /// Construct a v1 provider-log entry body. + pub fn new( + provider_id: ProviderId, + log_id: ProviderLogId, + account_id: AccountId, + subject: ProviderLogSubject, + observed_at: Timestamp, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + provider_id, + log_id, + account_id, + subject, + observed_at, + extensions, + }) + } + + /// Provider that observed this subject. + pub const fn provider_id(&self) -> ProviderId { + self.provider_id + } + + /// Provider-wide log generation. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Account whose object was observed. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Logged checkpoint or proposal intent. + pub const fn subject(&self) -> ProviderLogSubject { + self.subject + } + + /// Provider-signed observation time. + pub const fn observed_at(&self) -> Timestamp { + self.observed_at + } + + /// Derive the domain-separated leaf hash committed by the provider's append-only tree. + pub fn merkle_leaf_hash(&self) -> Result { + Ok(hash_bytes( + HashDomain::ProviderLogEntry, + &self.to_canonical_bytes()?, + )) + } +} + +impl<'de> Deserialize<'de> for ProviderLogEntryBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + provider_id: ProviderId, + log_id: ProviderLogId, + account_id: AccountId, + subject: ProviderLogSubject, + observed_at: Timestamp, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::new( + wire.provider_id, + wire.log_id, + wire.account_id, + wire.subject, + wire.observed_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(ProviderLogEntryBody, "provider log entry bytes"); + +/// Canonical signed-tree-head body for one provider-wide log. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderHeadBody { + protocol_version: ProtocolVersion, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + tree_size: u64, + tree_root: Digest, + observed_at: Timestamp, + extensions: Extensions, +} + +impl ProviderHeadBody { + /// Construct a signed-tree-head body. Tree size zero represents an empty log. + #[allow(clippy::too_many_arguments)] + pub fn new( + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + tree_size: u64, + tree_root: Digest, + observed_at: Timestamp, + extensions: Extensions, + ) -> Result { + let maximum_tree_size = u64::try_from(MAX_MERKLE_LOG_LEAVES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider Merkle log maximum tree size", + } + })?; + if tree_size > maximum_tree_size { + return Err(IdentityError::limit( + "provider Merkle log tree size", + usize::try_from(tree_size).unwrap_or(usize::MAX), + MAX_MERKLE_LOG_LEAVES, + )); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + provider_id, + log_id, + key_version, + tree_size, + tree_root, + observed_at, + extensions, + }) + } + + /// Provider signing this head. + pub const fn provider_id(&self) -> ProviderId { + self.provider_id + } + + /// Provider-wide log generation. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Signing-key generation. + pub const fn key_version(&self) -> ProviderKeyVersion { + self.key_version + } + + /// Number of leaves committed by this head. + pub const fn tree_size(&self) -> u64 { + self.tree_size + } + + /// Root of the exact provider-wide append-only tree size. + pub const fn tree_root(&self) -> Digest { + self.tree_root + } + + /// Provider-observed head time. + pub const fn observed_at(&self) -> Timestamp { + self.observed_at + } + + /// Literal domain-separated bytes signed by the configured provider key. + pub fn signing_bytes(&self) -> Result, IdentityError> { + let body = self.to_canonical_bytes()?; + let capacity = PROVIDER_HEAD_SIGNATURE_DOMAIN + .len() + .checked_add(1) + .and_then(|length| length.checked_add(body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider head signing message length", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(PROVIDER_HEAD_SIGNATURE_DOMAIN); + message.push(0); + message.extend_from_slice(&body); + Ok(message) + } +} + +impl<'de> Deserialize<'de> for ProviderHeadBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + tree_size: u64, + tree_root: Digest, + observed_at: Timestamp, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::new( + wire.provider_id, + wire.log_id, + wire.key_version, + wire.tree_size, + wire.tree_root, + wire.observed_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(ProviderHeadBody, "provider head bytes"); + +/// Provider head paired with its provider signature. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedProviderHead { + body: ProviderHeadBody, + signature: ProtocolSignature, +} + +impl SignedProviderHead { + /// Attach a provider signature to a head body. + pub const fn new(body: ProviderHeadBody, signature: ProtocolSignature) -> Self { + Self { body, signature } + } + + /// Signed head body. + pub const fn body(&self) -> &ProviderHeadBody { + &self.body + } + + /// Provider signature bytes. + pub const fn signature(&self) -> ProtocolSignature { + self.signature + } + + /// Verify this head under the exact configured v1 provider descriptor. + pub fn verify(&self, provider: &ProviderDescriptor) -> Result<(), IdentityError> { + if provider.id()? != self.body.provider_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider head configured descriptor", + }); + } + if self.body.key_version != ProviderKeyVersion::GENESIS { + return Err(IdentityError::InvalidRelationship { + resource: "provider head signing key version", + }); + } + if self.body.tree_size == 0 && self.body.tree_root != empty_merkle_root() { + return Err(IdentityError::InvalidProof); + } + let public_key = PublicKey::from_bytes(provider.signing_key().as_bytes()) + .map_err(|_| IdentityError::InvalidSignature)?; + let signature = Signature::try_from(self.signature.as_bytes().as_slice()) + .map_err(|_| IdentityError::InvalidSignature)?; + public_key + .verify(&self.body.signing_bytes()?, &signature) + .map_err(|_| IdentityError::InvalidSignature) + } +} + +canonical_schema!(SignedProviderHead, "signed provider head bytes"); + +/// Two authenticated same-size heads proving that one provider equivocated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderEquivocationEvidence { + first: SignedProviderHead, + second: SignedProviderHead, +} + +impl ProviderEquivocationEvidence { + /// Validate and retain a pair of conflicting signed heads from one configured provider. + pub fn new( + provider: &ProviderDescriptor, + first: SignedProviderHead, + second: SignedProviderHead, + ) -> Result { + first.verify(provider)?; + second.verify(provider)?; + Self::from_heads(first, second) + } + + fn from_heads( + first: SignedProviderHead, + second: SignedProviderHead, + ) -> Result { + if first.body.provider_id != second.body.provider_id + || first.body.log_id != second.body.log_id + || first.body.tree_size != second.body.tree_size + || first.body.tree_root == second.body.tree_root + { + return Err(IdentityError::InvalidRelationship { + resource: "provider equivocation head pair", + }); + } + Ok(Self { first, second }) + } + + /// First conflicting signed head. + pub const fn first(&self) -> &SignedProviderHead { + &self.first + } + + /// Second conflicting signed head. + pub const fn second(&self) -> &SignedProviderHead { + &self.second + } + + /// Reverify both signatures and the same-size/different-root relationship. + pub fn verify(&self, provider: &ProviderDescriptor) -> Result<(), IdentityError> { + self.first.verify(provider)?; + self.second.verify(provider)?; + Self::from_heads(self.first.clone(), self.second.clone()).map(|_| ()) + } +} + +impl<'de> Deserialize<'de> for ProviderEquivocationEvidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (first, second) = + <(SignedProviderHead, SignedProviderHead)>::deserialize(deserializer)?; + Self::from_heads(first, second).map_err(de::Error::custom) + } +} + +canonical_schema!( + ProviderEquivocationEvidence, + "provider equivocation evidence bytes" +); + +/// Verify monotonic append-only progression between two authenticated provider heads. +pub fn verify_provider_head_progression( + provider: &ProviderDescriptor, + older: &SignedProviderHead, + newer: &SignedProviderHead, + consistency_proof: &MerkleConsistencyProof, +) -> Result<(), IdentityError> { + older.verify(provider)?; + newer.verify(provider)?; + if older.body.provider_id != newer.body.provider_id || older.body.log_id != newer.body.log_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider head progression log", + }); + } + if newer.body.tree_size < older.body.tree_size + || newer.body.observed_at < older.body.observed_at + { + return Err(IdentityError::ProviderRollback); + } + if newer.body.tree_size == older.body.tree_size && newer.body.tree_root != older.body.tree_root + { + return Err(IdentityError::ProviderEquivocation); + } + if consistency_proof.old_size() != older.body.tree_size + || consistency_proof.new_size() != newer.body.tree_size + { + return Err(IdentityError::InvalidProof); + } + consistency_proof.verify(older.body.tree_root, newer.body.tree_root) +} + +/// Bounded inclusion evidence for one provider log entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct InclusionReceipt { + entry: ProviderLogEntryBody, + leaf_index: u64, + audit_path: BoundedVec, + signed_head: SignedProviderHead, +} + +impl InclusionReceipt { + /// Construct structurally consistent bounded inclusion evidence. + pub fn new( + entry: ProviderLogEntryBody, + leaf_index: u64, + audit_path: Vec, + signed_head: SignedProviderHead, + ) -> Result { + if entry.provider_id() != signed_head.body().provider_id() + || entry.log_id() != signed_head.body().log_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "provider receipt entry/head", + }); + } + if leaf_index >= signed_head.body().tree_size() { + return Err(IdentityError::InvalidRelationship { + resource: "provider receipt leaf index/tree size", + }); + } + Ok(Self { + entry, + leaf_index, + audit_path: BoundedVec::new("Merkle audit path", audit_path)?, + signed_head, + }) + } + + /// Provider that issued this receipt. + pub const fn provider_id(&self) -> ProviderId { + self.entry.provider_id() + } + + /// Zero-based provider-log leaf index. + pub const fn leaf_index(&self) -> u64 { + self.leaf_index + } + + /// Logged entry. + pub const fn entry(&self) -> &ProviderLogEntryBody { + &self.entry + } + + /// Signed provider head that commits the entry and supplies the historical observation time. + pub const fn signed_head(&self) -> &SignedProviderHead { + &self.signed_head + } + + /// Bottom-up Merkle audit path. + pub fn audit_path(&self) -> &[Digest] { + self.audit_path.as_slice() + } + + /// Verify provider identity/signature, observation ordering, and exact Merkle inclusion. + pub fn verify(&self, provider: &ProviderDescriptor) -> Result<(), IdentityError> { + self.signed_head.verify(provider)?; + if self.entry.provider_id != self.signed_head.body.provider_id + || self.entry.log_id != self.signed_head.body.log_id + { + return Err(IdentityError::InvalidRelationship { + resource: "provider receipt entry/head", + }); + } + if self.signed_head.body.observed_at < self.entry.observed_at { + return Err(IdentityError::InvalidRelationship { + resource: "provider head observation time", + }); + } + MerkleInclusionProof::new( + self.leaf_index, + self.signed_head.body.tree_size, + self.audit_path.as_slice().to_vec(), + )? + .verify_leaf_hash( + self.entry.merkle_leaf_hash()?, + self.signed_head.body.tree_root, + ) + } +} + +impl<'de> Deserialize<'de> for InclusionReceipt { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + entry: ProviderLogEntryBody, + leaf_index: u64, + audit_path: BoundedVec, + signed_head: SignedProviderHead, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new( + wire.entry, + wire.leaf_index, + wire.audit_path.into_vec(), + wire.signed_head, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(InclusionReceipt, "provider inclusion receipt bytes"); + +/// Sorted, duplicate-free receipts for one account subject. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderReceipts(BoundedVec); + +impl ProviderReceipts { + /// Sort and construct receipts from distinct providers for one subject. + pub fn new(mut receipts: Vec) -> Result { + receipts.sort_unstable_by_key(InclusionReceipt::provider_id); + Self::from_sorted(receipts) + } + + /// Canonically ordered receipts. + pub fn as_slice(&self) -> &[InclusionReceipt] { + self.0.as_slice() + } + + fn from_sorted(receipts: Vec) -> Result { + let receipts = BoundedVec::new("provider receipts", receipts)?; + for pair in receipts.as_slice().windows(2) { + if pair[0].provider_id() == pair[1].provider_id() { + return Err(IdentityError::DuplicateElement { + resource: "provider receipts", + }); + } + if pair[0].provider_id() > pair[1].provider_id() { + return Err(IdentityError::NonCanonical); + } + } + if let Some(first) = receipts.as_slice().first() { + for receipt in &receipts.as_slice()[1..] { + if receipt.entry().account_id() != first.entry().account_id() + || receipt.entry().subject() != first.entry().subject() + { + return Err(IdentityError::InvalidRelationship { + resource: "provider receipt subject set", + }); + } + } + } + Ok(Self(receipts)) + } +} + +impl<'de> Deserialize<'de> for ProviderReceipts { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let receipts = + BoundedVec::::deserialize(deserializer)?; + Self::from_sorted(receipts.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(ProviderReceipts, "provider receipt set bytes"); + +/// Account lifecycle committed by a checkpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountLifecycle { + /// Ordinary active authority. + Active, + /// One authoritative recovery is pending. + RecoveryPending, + /// A new controller-signature suite is staged. + MigrationPending, + /// Old and new controller suites are both required. + MigrationDual, + /// A protocol-major upgrade was authorized; v1 is read-only. + UpgradePending, + /// Terminally retired account. + Retired, +} + +impl AccountLifecycle { + /// Stable v1 lifecycle codepoint. + pub const fn code(self) -> u16 { + match self { + Self::Active => 1, + Self::RecoveryPending => 2, + Self::MigrationPending => 3, + Self::MigrationDual => 4, + Self::UpgradePending => 5, + Self::Retired => 6, + } + } +} + +impl<'de> Deserialize<'de> for AccountLifecycle { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match u16::deserialize(deserializer)? { + 1 => Ok(Self::Active), + 2 => Ok(Self::RecoveryPending), + 3 => Ok(Self::MigrationPending), + 4 => Ok(Self::MigrationDual), + 5 => Ok(Self::UpgradePending), + 6 => Ok(Self::Retired), + code => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "account lifecycle", + code, + })), + } + } +} + +impl Serialize for AccountLifecycle { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +/// Canonical account-state checkpoint body. Authorization is an outer envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CheckpointBody { + protocol_version: ProtocolVersion, + account_id: AccountId, + account_epoch: Epoch, + sequence: Sequence, + event_head: EventId, + state_root: Digest, + authorized_set_root: Digest, + revoked_set_root: Digest, + control_policy_id: ControlPolicyId, + recovery_policy_id: RecoveryPolicyId, + provider_policy_id: ProviderPolicyId, + crypto_state_id: CryptoStateId, + lifecycle: AccountLifecycle, + issued_at: Timestamp, + extensions: Extensions, +} + +impl CheckpointBody { + /// Construct a canonical single-head checkpoint body. + #[allow(clippy::too_many_arguments)] + pub fn new( + account_id: AccountId, + account_epoch: Epoch, + sequence: Sequence, + event_head: EventId, + state_root: Digest, + authorized_set_root: Digest, + revoked_set_root: Digest, + control_policy_id: ControlPolicyId, + recovery_policy_id: RecoveryPolicyId, + provider_policy_id: ProviderPolicyId, + crypto_state_id: CryptoStateId, + lifecycle: AccountLifecycle, + issued_at: Timestamp, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + account_id, + account_epoch, + sequence, + event_head, + state_root, + authorized_set_root, + revoked_set_root, + control_policy_id, + recovery_policy_id, + provider_policy_id, + crypto_state_id, + lifecycle, + issued_at, + extensions, + }) + } + + /// Derive the stable body-only checkpoint identifier. + pub fn checkpoint_id(&self) -> Result { + CheckpointId::derive(self) + } + + /// Account committed by this checkpoint. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Security epoch committed by this checkpoint. + pub const fn account_epoch(&self) -> Epoch { + self.account_epoch + } + + /// Account-event sequence committed by this checkpoint. + pub const fn sequence(&self) -> Sequence { + self.sequence + } + + /// Authoritative event head committed by this checkpoint. + pub const fn event_head(&self) -> EventId { + self.event_head + } + + /// Complete deterministic projected-state root. + pub const fn state_root(&self) -> Digest { + self.state_root + } + + /// Active authorized-device set root. + pub const fn authorized_set_root(&self) -> Digest { + self.authorized_set_root + } + + /// Permanent revoked-device tombstone set root. + pub const fn revoked_set_root(&self) -> Digest { + self.revoked_set_root + } + + /// Control policy active at this checkpoint. + pub const fn control_policy_id(&self) -> ControlPolicyId { + self.control_policy_id + } + + /// Recovery policy active at this checkpoint. + pub const fn recovery_policy_id(&self) -> RecoveryPolicyId { + self.recovery_policy_id + } + + /// Provider policy active at this checkpoint. + pub const fn provider_policy_id(&self) -> ProviderPolicyId { + self.provider_policy_id + } + + /// Projected controller-signature migration state. + pub const fn crypto_state_id(&self) -> CryptoStateId { + self.crypto_state_id + } + + /// Projected account lifecycle committed by this checkpoint. + pub const fn lifecycle(&self) -> AccountLifecycle { + self.lifecycle + } + + /// Account-supplied issuance metadata, never an authority time source. + pub const fn issued_at(&self) -> Timestamp { + self.issued_at + } +} + +impl<'de> Deserialize<'de> for CheckpointBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + account_epoch: Epoch, + sequence: Sequence, + event_head: EventId, + state_root: Digest, + authorized_set_root: Digest, + revoked_set_root: Digest, + control_policy_id: ControlPolicyId, + recovery_policy_id: RecoveryPolicyId, + provider_policy_id: ProviderPolicyId, + crypto_state_id: CryptoStateId, + lifecycle: AccountLifecycle, + issued_at: Timestamp, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new( + wire.account_id, + wire.account_epoch, + wire.sequence, + wire.event_head, + wire.state_root, + wire.authorized_set_root, + wire.revoked_set_root, + wire.control_policy_id, + wire.recovery_policy_id, + wire.provider_policy_id, + wire.crypto_state_id, + wire.lifecycle, + wire.issued_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(CheckpointBody, "account checkpoint body bytes"); + +/// Authority-destructive transition eligible to authorize its immediate checkpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum CheckpointTransitionKind { + /// Successful recovery finalization installed replacement authority. + FinalizeRecovery, + /// Terminal account retirement removed all ordinary authority. + RetireAccount, +} + +impl CheckpointTransitionKind { + /// Stable v1 transition-witness codepoint. + pub const fn code(self) -> u16 { + match self { + Self::FinalizeRecovery => 1, + Self::RetireAccount => 2, + } + } +} + +impl Serialize for CheckpointTransitionKind { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for CheckpointTransitionKind { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match u16::deserialize(deserializer)? { + 1 => Ok(Self::FinalizeRecovery), + 2 => Ok(Self::RetireAccount), + code => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "checkpoint transition kind", + code, + })), + } + } +} + +canonical_schema!(CheckpointTransitionKind, "checkpoint transition kind bytes"); + +/// Typed reference to the complete proof for an authority-destructive account event. +/// +/// The corresponding [`AuthorizedEvent`] remains in the offline proof bundle. Verification +/// resolves both identifiers, confirms the operation kind, and recomputes the checkpoint body. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransitionCheckpointWitness { + protocol_version: ProtocolVersion, + transition_kind: CheckpointTransitionKind, + event_id: EventId, + event_authorization_id: EventAuthorizationId, +} + +impl TransitionCheckpointWitness { + fn from_authorized_event(event: &AuthorizedEvent) -> Result { + let transition_kind = match event.body().operation() { + AccountOperation::FinalizeRecovery(_) => CheckpointTransitionKind::FinalizeRecovery, + AccountOperation::RetireAccount(_) => CheckpointTransitionKind::RetireAccount, + _ => { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint transition witness operation", + }); + } + }; + Ok(Self { + protocol_version: ProtocolVersion::V1, + transition_kind, + event_id: event.event_id()?, + event_authorization_id: event.event_authorization_id()?, + }) + } + + /// Eligible transition class represented by this witness. + pub const fn transition_kind(&self) -> CheckpointTransitionKind { + self.transition_kind + } + + /// Body-only identifier of the authority-destructive event. + pub const fn event_id(&self) -> EventId { + self.event_id + } + + /// Domain-separated identifier of the exact retained authorization envelope. + pub const fn event_authorization_id(&self) -> EventAuthorizationId { + self.event_authorization_id + } +} + +canonical_schema!( + TransitionCheckpointWitness, + "transition checkpoint witness bytes" +); + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CheckpointAuthorizationKind { + Controllers(ControllerApprovals), + TransitionDerived(TransitionCheckpointWitness), +} + +/// Authorization for a checkpoint body, excluded from [`CheckpointId`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckpointAuthorization(CheckpointAuthorizationKind); + +impl CheckpointAuthorization { + /// Construct direct mergeable controller approvals for one checkpoint ID. + pub fn controllers( + checkpoint_id: CheckpointId, + approvals: ControllerApprovals, + ) -> Result { + if approvals + .as_slice() + .iter() + .any(|approval| approval.body().checkpoint_id() != Some(checkpoint_id)) + { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint controller approval subject", + }); + } + Ok(Self(CheckpointAuthorizationKind::Controllers(approvals))) + } + + /// Reference an eligible fully authorized event that deterministically yields the body. + pub fn transition_derived(event: &AuthorizedEvent) -> Result { + Ok(Self(CheckpointAuthorizationKind::TransitionDerived( + TransitionCheckpointWitness::from_authorized_event(event)?, + ))) + } + + /// Transition witness when this is transition-derived authorization. + pub const fn transition_witness(&self) -> Option<&TransitionCheckpointWitness> { + match &self.0 { + CheckpointAuthorizationKind::Controllers(_) => None, + CheckpointAuthorizationKind::TransitionDerived(witness) => Some(witness), + } + } + + /// Mergeable controller approvals when this is direct authorization. + pub const fn controller_approvals(&self) -> Option<&ControllerApprovals> { + match &self.0 { + CheckpointAuthorizationKind::Controllers(approvals) => Some(approvals), + CheckpointAuthorizationKind::TransitionDerived(_) => None, + } + } +} + +impl Serialize for CheckpointAuthorization { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.0 { + CheckpointAuthorizationKind::Controllers(approvals) => { + (1u16, approvals).serialize(serializer) + } + CheckpointAuthorizationKind::TransitionDerived(witness) => { + (2u16, witness).serialize(serializer) + } + } + } +} + +impl<'de> Deserialize<'de> for CheckpointAuthorization { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + impl<'de> de::Visitor<'de> for Visitor { + type Value = CheckpointAuthorization; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("v1 checkpoint authorization") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => Ok(CheckpointAuthorization( + CheckpointAuthorizationKind::Controllers( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + ), + )), + 2 => Ok(CheckpointAuthorization( + CheckpointAuthorizationKind::TransitionDerived( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + ), + )), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "checkpoint authorization", + code: unsupported, + })), + } + } + } + deserializer.deserialize_tuple(2, Visitor) + } +} + +canonical_schema!(CheckpointAuthorization, "checkpoint authorization bytes"); + +/// Checkpoint body paired with mergeable or transition-derived authorization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedCheckpoint { + body: CheckpointBody, + authorization: CheckpointAuthorization, +} + +impl SignedCheckpoint { + /// Construct an authorized checkpoint without changing its body-derived ID. + pub fn new( + body: CheckpointBody, + authorization: CheckpointAuthorization, + ) -> Result { + match &authorization.0 { + CheckpointAuthorizationKind::Controllers(approvals) => { + let checkpoint_id = body.checkpoint_id()?; + if approvals + .as_slice() + .iter() + .any(|approval| approval.body().checkpoint_id() != Some(checkpoint_id)) + { + return Err(IdentityError::InvalidRelationship { + resource: "signed checkpoint approval subject", + }); + } + } + CheckpointAuthorizationKind::TransitionDerived(witness) => { + let lifecycle_matches = match witness.transition_kind() { + CheckpointTransitionKind::FinalizeRecovery => { + body.lifecycle() == AccountLifecycle::Active + } + CheckpointTransitionKind::RetireAccount => { + body.lifecycle() == AccountLifecycle::Retired + } + }; + if witness.event_id() != body.event_head() || !lifecycle_matches { + return Err(IdentityError::InvalidRelationship { + resource: "signed checkpoint transition witness", + }); + } + } + } + Ok(Self { + body, + authorization, + }) + } + + /// Canonical checkpoint body. + pub const fn body(&self) -> &CheckpointBody { + &self.body + } + + /// Stable body-only checkpoint identifier. + pub fn checkpoint_id(&self) -> Result { + self.body.checkpoint_id() + } + + /// Direct or transition-derived authorization excluded from [`CheckpointId`]. + pub const fn authorization(&self) -> &CheckpointAuthorization { + &self.authorization + } + + /// Merge two authorization envelopes for the same checkpoint body. + /// + /// Direct controller approvals are a bounded canonical union. Transition-derived + /// authorization is deterministic and must match exactly. Authorization modes and bodies + /// never mix. + pub fn merge(&self, other: &Self) -> Result { + if self.body != other.body { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint authorization body", + }); + } + let authorization = match (&self.authorization.0, &other.authorization.0) { + ( + CheckpointAuthorizationKind::Controllers(left), + CheckpointAuthorizationKind::Controllers(right), + ) => CheckpointAuthorization::controllers(self.checkpoint_id()?, left.merge(right)?)?, + ( + CheckpointAuthorizationKind::TransitionDerived(left), + CheckpointAuthorizationKind::TransitionDerived(right), + ) if left == right => { + CheckpointAuthorization(CheckpointAuthorizationKind::TransitionDerived(*left)) + } + ( + CheckpointAuthorizationKind::Controllers(_), + CheckpointAuthorizationKind::TransitionDerived(_), + ) + | ( + CheckpointAuthorizationKind::TransitionDerived(_), + CheckpointAuthorizationKind::Controllers(_), + ) => { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint authorization mode", + }); + } + ( + CheckpointAuthorizationKind::TransitionDerived(_), + CheckpointAuthorizationKind::TransitionDerived(_), + ) => { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint transition witness", + }); + } + }; + Self::new(self.body.clone(), authorization) + } +} + +impl<'de> Deserialize<'de> for SignedCheckpoint { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + body: CheckpointBody, + authorization: CheckpointAuthorization, + } + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.body, wire.authorization).map_err(de::Error::custom) + } +} + +canonical_schema!(SignedCheckpoint, "signed checkpoint bytes"); + +/// Result of bounded checkpoint bootstrap from genesis or a prior verified anchor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedCheckpointBootstrap { + state: crate::AccountState, + checkpoint: VerifiedCheckpoint, + freshness: crate::FreshnessDecision, +} + +impl TrustedCheckpointBootstrap { + /// Fully projected account state named by the trusted checkpoint. + pub const fn state(&self) -> &crate::AccountState { + &self.state + } + + /// Checkpoint verified against the complete bounded proof chain. + pub const fn checkpoint(&self) -> &VerifiedCheckpoint { + &self.checkpoint + } + + /// Exact checkpoint/epoch/provider-time basis of bootstrap acceptance. + pub const fn freshness(&self) -> crate::FreshnessDecision { + self.freshness + } +} + +/// Checkpoint whose complete body and authorization were verified against projected state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedCheckpoint { + checkpoint: SignedCheckpoint, + checkpoint_id: CheckpointId, + transition_event: Option, +} + +impl VerifiedCheckpoint { + /// Fully verified signed checkpoint. + pub const fn checkpoint(&self) -> &SignedCheckpoint { + &self.checkpoint + } + + /// Stable body-only checkpoint identifier. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Complete retained destructive transition, when transition-derived authorization was used. + pub const fn transition_event(&self) -> Option<&AuthorizedEvent> { + self.transition_event.as_ref() + } +} + +/// Trust anchor carried by one bounded provider-served checkpoint lineage link. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderCheckpointLineage { + /// Self-contained lineage beginning at the stable account genesis. + Genesis(Box), + /// Bounded continuation from a prior checkpoint retained by the same provider. + Prior(CheckpointId), +} + +/// Bounded verified checkpoint plus the authority material a provider must retain and serve. +/// +/// A provider log leaf commits only the checkpoint ID, so retaining this bundle is what lets a +/// previously unprovisioned verifier retrieve the signed checkpoint and replay its authenticated +/// lineage instead of learning only an opaque digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCheckpointBundle { + lineage: ProviderCheckpointLineage, + events: Vec, + verified_checkpoint: VerifiedCheckpoint, +} + +impl ProviderCheckpointBundle { + /// Genesis anchor when this link is independently replayable from account creation. + pub fn genesis(&self) -> Option<&crate::AccountGenesis> { + match &self.lineage { + ProviderCheckpointLineage::Genesis(genesis) => Some(genesis.as_ref()), + ProviderCheckpointLineage::Prior(_) => None, + } + } + + /// Prior checkpoint required before replaying this continuation link. + pub const fn prior_checkpoint_id(&self) -> Option { + match self.lineage { + ProviderCheckpointLineage::Genesis(_) => None, + ProviderCheckpointLineage::Prior(checkpoint_id) => Some(checkpoint_id), + } + } + + /// Exact bounded advancing event chain in semantic order. + pub fn events(&self) -> &[AuthorizedEvent] { + &self.events + } + + /// Checkpoint verified against the state produced by this lineage link. + pub const fn verified_checkpoint(&self) -> &VerifiedCheckpoint { + &self.verified_checkpoint + } + + /// Create the only public checkpoint-shaped provider admission capability. + pub fn provider_log_admission(&self) -> crate::ProviderLogAdmission { + crate::ProviderLogAdmission::checkpoint(self.clone()) + } + + /// Merge independently verified authorization evidence for the same retained lineage link. + /// + /// The checkpoint ID commits only the body, so providers may observe valid controller + /// approval subsets in either order. Lineage and transition evidence must still match exactly; + /// only the already-verified direct approval envelope is mergeable. + pub(crate) fn merge_approval_evidence(&self, other: &Self) -> Result { + if self.lineage != other.lineage + || self.events != other.events + || self.verified_checkpoint.transition_event + != other.verified_checkpoint.transition_event + { + return Err(IdentityError::InvalidRelationship { + resource: "provider checkpoint lineage merge", + }); + } + let checkpoint = self + .verified_checkpoint + .checkpoint + .merge(&other.verified_checkpoint.checkpoint)?; + let checkpoint_id = checkpoint.checkpoint_id()?; + if checkpoint_id != self.verified_checkpoint.checkpoint_id + || checkpoint_id != other.verified_checkpoint.checkpoint_id + { + return Err(IdentityError::InvalidProof); + } + Ok(Self { + lineage: self.lineage.clone(), + events: self.events.clone(), + verified_checkpoint: VerifiedCheckpoint { + checkpoint, + checkpoint_id, + transition_event: self.verified_checkpoint.transition_event.clone(), + }, + }) + } +} + +/// Build a bounded provider-served checkpoint bundle by replaying from account genesis. +pub fn build_provider_checkpoint_bundle_from_genesis( + genesis: &crate::AccountGenesis, + events: &[AuthorizedEvent], + checkpoint: &SignedCheckpoint, + transition_event: Option<&AuthorizedEvent>, +) -> Result { + let mut state = crate::AccountState::from_genesis(genesis)?; + advance_checkpoint_lineage(&mut state, events)?; + let verified_checkpoint = verify_checkpoint(&state, checkpoint, transition_event)?; + Ok(ProviderCheckpointBundle { + lineage: ProviderCheckpointLineage::Genesis(Box::new(genesis.clone())), + events: events.to_vec(), + verified_checkpoint, + }) +} + +/// Build a bounded provider-served continuation from a prior verified checkpoint. +pub fn build_provider_checkpoint_bundle_from_prior( + prior_state: &crate::AccountState, + prior_checkpoint: &VerifiedCheckpoint, + events: &[AuthorizedEvent], + checkpoint: &SignedCheckpoint, + transition_event: Option<&AuthorizedEvent>, +) -> Result { + let prior_expected = + build_checkpoint_body(prior_state, prior_checkpoint.checkpoint.body.issued_at)?; + if prior_checkpoint.checkpoint.body != prior_expected { + return Err(IdentityError::InvalidProof); + } + let mut state = prior_state.clone(); + advance_checkpoint_lineage(&mut state, events)?; + let verified_checkpoint = verify_checkpoint(&state, checkpoint, transition_event)?; + Ok(ProviderCheckpointBundle { + lineage: ProviderCheckpointLineage::Prior(prior_checkpoint.checkpoint_id), + events: events.to_vec(), + verified_checkpoint, + }) +} + +fn advance_checkpoint_lineage( + state: &mut crate::AccountState, + events: &[AuthorizedEvent], +) -> Result<(), IdentityError> { + validate_bootstrap_bounds(events, &[])?; + for event in events { + // Complete bounded lineage may pass through a detected fork only so a later, fully + // authorized ResolveFork event can consume the exact retained branches. The final + // lifecycle check below still rejects every unresolved or reopened fork. + match state.validate_and_apply(event)?.disposition() { + crate::ApplyDisposition::Applied | crate::ApplyDisposition::ForkDetected => {} + crate::ApplyDisposition::Replay | crate::ApplyDisposition::ApprovalsMerged => { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint lineage advancing event chain", + }); + } + } + } + if state.lifecycle() == crate::ProjectionLifecycle::Forked { + return Err(IdentityError::AccountForked); + } + Ok(()) +} + +/// Complete deterministic Merkle sets committed by one checkpoint projection. +/// +/// The retained sets let full-state holders serve exact inclusion and adjacent-neighbor +/// non-membership proofs without reimplementing the frozen checkpoint leaf schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckpointMerkleSets { + state: MerkleSet, + authorized_devices: MerkleSet, + revoked_devices: MerkleSet, +} + +impl CheckpointMerkleSets { + /// Complete authority-relevant state set, including metadata, controllers, and all devices. + pub const fn state(&self) -> &MerkleSet { + &self.state + } + + /// Devices currently active at this projection revision. + pub const fn authorized_devices(&self) -> &MerkleSet { + &self.authorized_devices + } + + /// Permanent revoked-device tombstones at this projection revision. + pub const fn revoked_devices(&self) -> &MerkleSet { + &self.revoked_devices + } +} + +/// Deterministically derive a complete single-head checkpoint body from projected account state. +pub fn build_checkpoint_body( + state: &crate::AccountState, + issued_at: Timestamp, +) -> Result { + let [event_head] = state.heads() else { + return Err(if state.lifecycle() == crate::ProjectionLifecycle::Forked { + IdentityError::AccountForked + } else { + IdentityError::InvalidRelationship { + resource: "checkpoint single event head", + } + }); + }; + let lifecycle = checkpoint_lifecycle(state.lifecycle())?; + let (state_root, authorized_set_root, revoked_set_root) = checkpoint_roots(state)?; + CheckpointBody::new( + state.account_id(), + state.epoch(), + state.sequence(), + *event_head, + state_root, + authorized_set_root, + revoked_set_root, + state.control_policy_id(), + state.recovery_policy_id(), + state.provider_policy_id(), + state.crypto_state_id()?, + lifecycle, + issued_at, + Extensions::default(), + ) +} + +/// Verify checkpoint roots, exact projection fields, and direct or transition authorization. +/// +/// Checkpoints do not advance account state. Direct authorization uses the current +/// `ChangeProviderPolicy` selector and weighted threshold because that existing v1 authority +/// controls which transparency providers receive the checkpoint. Destructive transition +/// authorization instead retains and replays the exact event. +pub fn verify_checkpoint( + state: &crate::AccountState, + checkpoint: &SignedCheckpoint, + transition_event: Option<&AuthorizedEvent>, +) -> Result { + let expected = build_checkpoint_body(state, checkpoint.body.issued_at)?; + if checkpoint.body != expected { + return Err(IdentityError::InvalidProof); + } + let checkpoint_id = checkpoint.checkpoint_id()?; + let retained_transition = match &checkpoint.authorization.0 { + CheckpointAuthorizationKind::Controllers(approvals) => { + if transition_event.is_some() { + return Err(IdentityError::InvalidRelationship { + resource: "direct checkpoint transition event", + }); + } + if approvals.as_slice().is_empty() { + return Err(IdentityError::AuthorizationDenied); + } + for approval in approvals.as_slice() { + if approval.body().checkpoint_id() != Some(checkpoint_id) { + return Err(IdentityError::InvalidRelationship { + resource: "verified checkpoint approval subject", + }); + } + } + crate::verifier::verify_checkpoint_approvals(state, approvals)?; + None + } + CheckpointAuthorizationKind::TransitionDerived(witness) => { + let event = transition_event.ok_or(IdentityError::InvalidProof)?; + if event.event_id()? != witness.event_id + || event.event_authorization_id()? != witness.event_authorization_id + || event.body().account_id() != state.account_id() + || event.event_id()? != checkpoint.body.event_head + { + return Err(IdentityError::InvalidProof); + } + let mut replay = state.clone(); + let disposition = replay.validate_and_apply(event)?.disposition(); + if !matches!( + disposition, + crate::ApplyDisposition::Replay | crate::ApplyDisposition::ApprovalsMerged + ) { + return Err(IdentityError::InvalidProof); + } + Some(event.clone()) + } + }; + Ok(VerifiedCheckpoint { + checkpoint: checkpoint.clone(), + checkpoint_id, + transition_event: retained_transition, + }) +} + +/// Bootstrap a checkpoint through a bounded authenticated event chain from account genesis. +#[allow(clippy::too_many_arguments)] +pub fn bootstrap_checkpoint_from_genesis( + genesis: &crate::AccountGenesis, + events: &[AuthorizedEvent], + checkpoint: &SignedCheckpoint, + transition_event: Option<&AuthorizedEvent>, + evidence: &crate::FreshnessEvidence, + caller_requirement: crate::FreshnessRequirement, + verified_at: Timestamp, + known_conflicts: &[EventId], +) -> Result { + let state = crate::AccountState::from_genesis(genesis)?; + bootstrap_checkpoint( + state, + events, + checkpoint, + transition_event, + evidence, + caller_requirement, + verified_at, + known_conflicts, + ) +} + +/// Advance from a prior verified checkpoint through a bounded policy-compatible proof chain. +#[allow(clippy::too_many_arguments)] +pub fn bootstrap_checkpoint_from_prior( + prior_state: &crate::AccountState, + prior_checkpoint: &VerifiedCheckpoint, + events: &[AuthorizedEvent], + checkpoint: &SignedCheckpoint, + transition_event: Option<&AuthorizedEvent>, + evidence: &crate::FreshnessEvidence, + caller_requirement: crate::FreshnessRequirement, + verified_at: Timestamp, + known_conflicts: &[EventId], +) -> Result { + let prior_expected = + build_checkpoint_body(prior_state, prior_checkpoint.checkpoint.body.issued_at)?; + if prior_checkpoint.checkpoint.body != prior_expected { + return Err(IdentityError::InvalidProof); + } + bootstrap_checkpoint( + prior_state.clone(), + events, + checkpoint, + transition_event, + evidence, + caller_requirement, + verified_at, + known_conflicts, + ) +} + +#[allow(clippy::too_many_arguments)] +fn bootstrap_checkpoint( + mut state: crate::AccountState, + events: &[AuthorizedEvent], + checkpoint: &SignedCheckpoint, + transition_event: Option<&AuthorizedEvent>, + evidence: &crate::FreshnessEvidence, + caller_requirement: crate::FreshnessRequirement, + verified_at: Timestamp, + known_conflicts: &[EventId], +) -> Result { + if !known_conflicts.is_empty() { + validate_bootstrap_bounds(events, known_conflicts)?; + return Err(IdentityError::AccountForked); + } + advance_checkpoint_lineage(&mut state, events)?; + let verified = verify_checkpoint(&state, checkpoint, transition_event)?; + let context = + crate::AuthorizationContext::new(state.account_id(), state.epoch(), verified.checkpoint_id); + let account_requirement = match state.provider_policy().mode() { + crate::ProviderMode::LocalOnly => crate::FreshnessRequirement::latest_known(), + crate::ProviderMode::Replicated(policy) => { + crate::FreshnessRequirement::provider_quorum(crate::ProviderFreshness::new( + policy.sufficient_threshold(), + policy.maximum_evidence_age(), + )?) + } + }; + let freshness = crate::evaluate_freshness( + context, + state.provider_policy(), + account_requirement, + caller_requirement, + evidence, + verified_at, + )?; + Ok(TrustedCheckpointBootstrap { + state, + checkpoint: verified, + freshness, + }) +} + +fn validate_bootstrap_bounds( + events: &[AuthorizedEvent], + known_conflicts: &[EventId], +) -> Result<(), IdentityError> { + if events.len() > crate::limits::MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "checkpoint bootstrap events", + events.len(), + crate::limits::MAX_HISTORY_PAGE_EVENTS, + )); + } + if known_conflicts.len() > crate::limits::MAX_FORK_HEADS { + return Err(IdentityError::limit( + "checkpoint bootstrap known conflicts", + known_conflicts.len(), + crate::limits::MAX_FORK_HEADS, + )); + } + let mut bytes = 0_usize; + for event in events { + bytes = bytes.checked_add(event.to_canonical_bytes()?.len()).ok_or( + IdentityError::ArithmeticOverflow { + resource: "checkpoint bootstrap proof bytes", + }, + )?; + if bytes > crate::limits::MAX_HISTORY_PAGE_BYTES { + return Err(IdentityError::limit( + "checkpoint bootstrap proof bytes", + bytes, + crate::limits::MAX_HISTORY_PAGE_BYTES, + )); + } + } + Ok(()) +} + +/// Build the exact sorted sets whose roots are committed by [`build_checkpoint_body`]. +pub fn build_checkpoint_merkle_sets( + state: &crate::AccountState, +) -> Result { + let state_material = state.checkpoint_state_material()?; + let metadata_key = MerkleSetKey::new( + CHECKPOINT_STATE_METADATA_TYPE_TAG, + hash_bytes(HashDomain::StateRoot, b"checkpoint-state-metadata-key"), + )?; + let mut state_leaves = vec![MerkleSetLeaf::new( + metadata_key, + hash_bytes(HashDomain::StateRoot, &state_material), + )]; + + for (controller, active) in state + .active_controllers() + .iter() + .map(|controller| (controller, true)) + .chain( + state + .revoked_controllers() + .iter() + .map(|controller| (controller, false)), + ) + { + let value = encode_wire(&(controller.id(), controller.descriptor(), active))?; + state_leaves.push(MerkleSetLeaf::new( + MerkleSetKey::new( + CHECKPOINT_STATE_CONTROLLER_TYPE_TAG, + *controller.id().as_digest(), + )?, + hash_bytes(HashDomain::StateRoot, &value), + )); + } + + let mut authorized_leaves = Vec::new(); + let mut revoked_leaves = Vec::new(); + for device in state.devices() { + let lifecycle_code = projected_device_lifecycle_code(device.lifecycle()); + let value = encode_wire(&( + device.id(), + device.descriptor(), + device.device_class(), + device.metadata_commitment(), + device.capabilities(), + device.authorization_epoch(), + lifecycle_code, + ))?; + state_leaves.push(MerkleSetLeaf::new( + MerkleSetKey::new(CHECKPOINT_STATE_DEVICE_TYPE_TAG, *device.id().as_digest())?, + hash_bytes(HashDomain::StateRoot, &value), + )); + match device.lifecycle() { + crate::ProjectedDeviceLifecycle::Active => { + authorized_leaves.push(MerkleSetLeaf::new( + MerkleSetKey::new( + CHECKPOINT_AUTHORIZED_DEVICE_TYPE_TAG, + *device.id().as_digest(), + )?, + hash_bytes(HashDomain::AuthorizedSet, &value), + )); + } + crate::ProjectedDeviceLifecycle::Suspended => {} + crate::ProjectedDeviceLifecycle::Revoked => { + revoked_leaves.push(MerkleSetLeaf::new( + MerkleSetKey::new( + CHECKPOINT_REVOKED_DEVICE_TYPE_TAG, + *device.id().as_digest(), + )?, + hash_bytes(HashDomain::RevokedSet, &value), + )); + } + } + } + + Ok(CheckpointMerkleSets { + state: MerkleSet::new(state_leaves)?, + authorized_devices: MerkleSet::new(authorized_leaves)?, + revoked_devices: MerkleSet::new(revoked_leaves)?, + }) +} + +fn checkpoint_roots( + state: &crate::AccountState, +) -> Result<(Digest, Digest, Digest), IdentityError> { + let sets = build_checkpoint_merkle_sets(state)?; + Ok(( + sets.state.root()?, + sets.authorized_devices.root()?, + sets.revoked_devices.root()?, + )) +} + +fn checkpoint_lifecycle( + lifecycle: crate::ProjectionLifecycle, +) -> Result { + match lifecycle { + crate::ProjectionLifecycle::Active => Ok(AccountLifecycle::Active), + crate::ProjectionLifecycle::RecoveryPending => Ok(AccountLifecycle::RecoveryPending), + crate::ProjectionLifecycle::MigrationPending => Ok(AccountLifecycle::MigrationPending), + crate::ProjectionLifecycle::MigrationDual => Ok(AccountLifecycle::MigrationDual), + crate::ProjectionLifecycle::UpgradePending => Ok(AccountLifecycle::UpgradePending), + crate::ProjectionLifecycle::Retired => Ok(AccountLifecycle::Retired), + crate::ProjectionLifecycle::Forked => Err(IdentityError::AccountForked), + } +} + +const fn projected_device_lifecycle_code(lifecycle: crate::ProjectedDeviceLifecycle) -> u16 { + match lifecycle { + crate::ProjectedDeviceLifecycle::Active => 1, + crate::ProjectedDeviceLifecycle::Suspended => 2, + crate::ProjectedDeviceLifecycle::Revoked => 3, + } +} diff --git a/protocols/krikos-identity/src/codec.rs b/protocols/krikos-identity/src/codec.rs new file mode 100644 index 00000000000..5cad186bb93 --- /dev/null +++ b/protocols/krikos-identity/src/codec.rs @@ -0,0 +1,72 @@ +//! Canonical Postcard v1 encoding for protocol-owned wire types. + +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{IdentityError, limits::MAX_ENCODED_OBJECT_BYTES}; + +pub(crate) mod sealed { + use super::IdentityError; + + pub trait CanonicalCodec: Sized { + const RESOURCE: &'static str; + const MAX_ENCODED_BYTES: usize = super::MAX_ENCODED_OBJECT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError>; + fn decode_canonical(bytes: &[u8]) -> Result; + } +} + +/// Canonical encoding available only for protocol-owned, sealed wire types. +/// +/// The v1 profile uses Postcard 1 and forbids maps, floats, `usize`, unordered +/// collections, trailing bytes, and non-minimal integer encodings in signed +/// structures. Implementations are sealed so arbitrary caller types cannot be +/// accidentally treated as signable protocol objects. +pub trait CanonicalWire: sealed::CanonicalCodec { + /// Encode this value into its unique canonical v1 byte representation. + fn to_canonical_bytes(&self) -> Result, IdentityError> { + let encoded = self.encode_canonical()?; + if encoded.len() > Self::MAX_ENCODED_BYTES { + return Err(IdentityError::limit( + Self::RESOURCE, + encoded.len(), + Self::MAX_ENCODED_BYTES, + )); + } + Ok(encoded) + } + + /// Decode a bounded canonical v1 byte representation. + fn from_canonical_bytes(bytes: &[u8]) -> Result { + if bytes.len() > Self::MAX_ENCODED_BYTES { + return Err(IdentityError::limit( + Self::RESOURCE, + bytes.len(), + Self::MAX_ENCODED_BYTES, + )); + } + Self::decode_canonical(bytes) + } +} + +impl CanonicalWire for T {} + +pub(crate) fn encode_wire(value: &T) -> Result, IdentityError> { + postcard::to_stdvec(value).map_err(|_| IdentityError::InvalidEncoding) +} + +pub(crate) fn decode_wire(bytes: &[u8]) -> Result +where + T: DeserializeOwned + Serialize, +{ + let (value, remaining) = + postcard::take_from_bytes::(bytes).map_err(|_| IdentityError::InvalidEncoding)?; + if !remaining.is_empty() { + return Err(IdentityError::NonCanonical); + } + let canonical = encode_wire(&value)?; + if canonical != bytes { + return Err(IdentityError::NonCanonical); + } + Ok(value) +} diff --git a/protocols/krikos-identity/src/crypto_migration.rs b/protocols/krikos-identity/src/crypto_migration.rs new file mode 100644 index 00000000000..b792b722016 --- /dev/null +++ b/protocols/krikos-identity/src/crypto_migration.rs @@ -0,0 +1,1323 @@ +//! Bounded cryptographic migration, protocol upgrade, and retirement wire schemas. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + AccountId, AeadAlgorithm, AgreementAlgorithm, AlgorithmPublicKey, AlgorithmSignature, + ControllerId, ControllerKeyId, CryptoMigrationId, CryptoSuiteId, Digest, EventId, Extensions, + HashAlgorithm, IdentityError, KdfAlgorithm, ProtocolMajor, ProtocolVersion, + RevocationReasonCode, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_ACCOUNT_EVENT_BYTES, MAX_CONTROLLERS}, + schema::BoundedVec, +}; + +const CRYPTO_SUITE_RETIREMENT_ABORT_CANDIDATE_CODE: u16 = 1; +const CRYPTO_SUITE_RETIREMENT_RETIRE_PREVIOUS_CODE: u16 = 2; +const UPGRADE_COMPATIBILITY_OLD_CLIENTS_READ_ONLY_CODE: u16 = 1; + +macro_rules! canonical_wire { + ($type:ty, $resource:literal) => { + impl CanonicalCodec for $type { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; + ($type:ty, $resource:literal, $maximum:expr) => { + impl CanonicalCodec for $type { + const RESOURCE: &'static str = $resource; + const MAX_ENCODED_BYTES: usize = $maximum; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +fn validate_nonzero_code(code: u16, resource: &'static str) -> Result<(), IdentityError> { + if code == 0 { + return Err(IdentityError::ZeroValue { resource }); + } + Ok(()) +} + +/// A versioned, algorithm-tagged cryptographic suite descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CryptoSuiteDescriptor { + version: ProtocolVersion, + suite_code: u16, + hash_algorithm_code: u16, + signature_algorithm_code: u16, + agreement_algorithm_code: u16, + kdf_algorithm_code: u16, + aead_algorithm_code: u16, + extensions: Extensions, +} + +impl CryptoSuiteDescriptor { + /// Initial v1 suite: BLAKE3, Ed25519, X25519, BLAKE3 KDF, and XChaCha20-Poly1305. + pub fn v1() -> Result { + Self::try_new( + ProtocolVersion::V1, + 1, + HashAlgorithm::Blake3_256.code(), + crate::SignatureAlgorithm::Ed25519.code(), + AgreementAlgorithm::X25519.code(), + KdfAlgorithm::Blake3DeriveKey.code(), + AeadAlgorithm::XChaCha20Poly1305.code(), + Extensions::default(), + ) + } + + /// Construct a suite descriptor from nonzero registry codepoints. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + version: ProtocolVersion, + suite_code: u16, + hash_algorithm_code: u16, + signature_algorithm_code: u16, + agreement_algorithm_code: u16, + kdf_algorithm_code: u16, + aead_algorithm_code: u16, + extensions: Extensions, + ) -> Result { + Self::from_canonical_wire( + version, + suite_code, + hash_algorithm_code, + signature_algorithm_code, + agreement_algorithm_code, + kdf_algorithm_code, + aead_algorithm_code, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_canonical_wire( + version: ProtocolVersion, + suite_code: u16, + hash_algorithm_code: u16, + signature_algorithm_code: u16, + agreement_algorithm_code: u16, + kdf_algorithm_code: u16, + aead_algorithm_code: u16, + extensions: Extensions, + ) -> Result { + validate_nonzero_code(suite_code, "cryptographic suite code")?; + validate_nonzero_code(hash_algorithm_code, "hash algorithm code")?; + validate_nonzero_code(signature_algorithm_code, "signature algorithm code")?; + validate_nonzero_code(agreement_algorithm_code, "agreement algorithm code")?; + validate_nonzero_code(kdf_algorithm_code, "key derivation algorithm code")?; + validate_nonzero_code( + aead_algorithm_code, + "authenticated encryption algorithm code", + )?; + extensions.validate_critical(&[])?; + Ok(Self { + version, + suite_code, + hash_algorithm_code, + signature_algorithm_code, + agreement_algorithm_code, + kdf_algorithm_code, + aead_algorithm_code, + extensions, + }) + } + + /// Schema version of this descriptor. + pub const fn version(&self) -> ProtocolVersion { + self.version + } + + /// Stable cryptographic-suite registry code. + pub const fn suite_code(&self) -> u16 { + self.suite_code + } + + /// Hash-algorithm registry code. + pub const fn hash_algorithm_code(&self) -> u16 { + self.hash_algorithm_code + } + + /// Signature-algorithm registry code. + pub const fn signature_algorithm_code(&self) -> u16 { + self.signature_algorithm_code + } + + /// Key-agreement-algorithm registry code. + pub const fn agreement_algorithm_code(&self) -> u16 { + self.agreement_algorithm_code + } + + /// Key-derivation-algorithm registry code. + pub const fn kdf_algorithm_code(&self) -> u16 { + self.kdf_algorithm_code + } + + /// Authenticated-encryption-algorithm registry code. + pub const fn aead_algorithm_code(&self) -> u16 { + self.aead_algorithm_code + } + + /// Signed extension fields, which are the descriptor's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Derive the domain-separated identifier of this canonical descriptor. + pub fn crypto_suite_id(&self) -> Result { + CryptoSuiteId::derive(self) + } + + fn permits_v1_in_place_migration(&self) -> bool { + self.hash_algorithm_code == HashAlgorithm::Blake3_256.code() + && self.agreement_algorithm_code == AgreementAlgorithm::X25519.code() + && self.kdf_algorithm_code == KdfAlgorithm::Blake3DeriveKey.code() + && self.aead_algorithm_code == AeadAlgorithm::XChaCha20Poly1305.code() + } +} + +impl<'de> Deserialize<'de> for CryptoSuiteDescriptor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + version: ProtocolVersion, + suite_code: u16, + hash_algorithm_code: u16, + signature_algorithm_code: u16, + agreement_algorithm_code: u16, + kdf_algorithm_code: u16, + aead_algorithm_code: u16, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.version, + wire.suite_code, + wire.hash_algorithm_code, + wire.signature_algorithm_code, + wire.agreement_algorithm_code, + wire.kdf_algorithm_code, + wire.aead_algorithm_code, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + CryptoSuiteDescriptor, + "cryptographic suite descriptor bytes" +); + +/// One controller's old key identifier and bounded candidate signing key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ControllerKeyBinding { + controller_id: ControllerId, + old_key_id: ControllerKeyId, + new_signing_key: AlgorithmPublicKey, + extensions: Extensions, +} + +impl ControllerKeyBinding { + /// Construct one controller key binding. + pub fn try_new( + controller_id: ControllerId, + old_key_id: ControllerKeyId, + new_signing_key: AlgorithmPublicKey, + extensions: Extensions, + ) -> Result { + Self::from_canonical_wire(controller_id, old_key_id, new_signing_key, extensions) + } + + fn from_canonical_wire( + controller_id: ControllerId, + old_key_id: ControllerKeyId, + new_signing_key: AlgorithmPublicKey, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + controller_id, + old_key_id, + new_signing_key, + extensions, + }) + } + + /// Controller whose signing key is being migrated. + pub const fn controller_id(&self) -> ControllerId { + self.controller_id + } + + /// Identifier of the controller's previously active key. + pub const fn old_key_id(&self) -> ControllerKeyId { + self.old_key_id + } + + /// Candidate algorithm-tagged signing public key. + pub const fn new_signing_key(&self) -> &AlgorithmPublicKey { + &self.new_signing_key + } + + /// Signed extension fields, which are the binding's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for ControllerKeyBinding { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + controller_id: ControllerId, + old_key_id: ControllerKeyId, + new_signing_key: AlgorithmPublicKey, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.controller_id, + wire.old_key_id, + wire.new_signing_key, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!(ControllerKeyBinding, "controller key binding bytes"); + +/// Canonical body describing a complete controller-signature-suite migration. +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct CryptoMigrationBody { + version: ProtocolVersion, + account_id: AccountId, + from_suite_id: CryptoSuiteId, + to_suite: CryptoSuiteDescriptor, + bindings: BoundedVec, + successor_account_id: Option, + nonce: [u8; 32], + extensions: Extensions, +} + +impl CryptoMigrationBody { + /// Validate, sort, and construct a migration body. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + version: ProtocolVersion, + account_id: AccountId, + from_suite_id: CryptoSuiteId, + to_suite: CryptoSuiteDescriptor, + bindings: Vec, + successor_account_id: Option, + nonce: [u8; 32], + extensions: Extensions, + ) -> Result { + let mut bindings = BoundedVec::::new( + "controller key bindings", + bindings, + )? + .into_vec(); + bindings.sort_unstable_by_key(ControllerKeyBinding::controller_id); + Self::from_canonical_wire( + version, + account_id, + from_suite_id, + to_suite, + BoundedVec::new("controller key bindings", bindings)?, + successor_account_id, + nonce, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_canonical_wire( + version: ProtocolVersion, + account_id: AccountId, + from_suite_id: CryptoSuiteId, + to_suite: CryptoSuiteDescriptor, + bindings: BoundedVec, + successor_account_id: Option, + nonce: [u8; 32], + extensions: Extensions, + ) -> Result { + let binding_slice = bindings.as_slice(); + if binding_slice.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "controller key bindings", + }); + } + for pair in binding_slice.windows(2) { + if pair[0].controller_id() == pair[1].controller_id() { + return Err(IdentityError::DuplicateElement { + resource: "controller key bindings", + }); + } + if pair[0].controller_id() > pair[1].controller_id() { + return Err(IdentityError::NonCanonical); + } + } + for (position, left) in binding_slice.iter().enumerate() { + for right in binding_slice.iter().skip(position.saturating_add(1)) { + if left.old_key_id() == right.old_key_id() { + return Err(IdentityError::DuplicateElement { + resource: "old controller key identifiers", + }); + } + if left.new_signing_key() == right.new_signing_key() { + return Err(IdentityError::DuplicateSigningKey); + } + } + if left.new_signing_key().algorithm_code() != to_suite.signature_algorithm_code() { + return Err(IdentityError::InvalidRelationship { + resource: "migration signing key algorithm", + }); + } + } + if nonce.iter().all(|byte| *byte == 0) { + return Err(IdentityError::ZeroValue { + resource: "cryptographic migration nonce", + }); + } + if successor_account_id.is_some_and(|successor| successor == account_id) { + return Err(IdentityError::InvalidRelationship { + resource: "migration successor account", + }); + } + if successor_account_id.is_none() && !to_suite.permits_v1_in_place_migration() { + return Err(IdentityError::InvalidRelationship { + resource: "in-place cryptographic migration suite", + }); + } + if from_suite_id == to_suite.crypto_suite_id()? { + return Err(IdentityError::InvalidRelationship { + resource: "cryptographic migration suites", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + version, + account_id, + from_suite_id, + to_suite, + bindings, + successor_account_id, + nonce, + extensions, + }) + } + + /// Schema version of this migration body. + pub const fn version(&self) -> ProtocolVersion { + self.version + } + + /// Account whose controller suite is being migrated. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Identifier of the suite active before this migration. + pub const fn from_suite_id(&self) -> CryptoSuiteId { + self.from_suite_id + } + + /// Candidate cryptographic suite descriptor. + pub const fn to_suite(&self) -> &CryptoSuiteDescriptor { + &self.to_suite + } + + /// Canonically sorted controller key bindings. + pub fn bindings(&self) -> &[ControllerKeyBinding] { + self.bindings.as_slice() + } + + /// Cross-certified successor account required by a digest-breaking migration. + pub const fn successor_account_id(&self) -> Option { + self.successor_account_id + } + + /// Nonzero migration nonce. + pub const fn nonce(&self) -> &[u8; 32] { + &self.nonce + } + + /// Signed extension fields, which are the migration body's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Derive the domain-separated identifier of this canonical migration body. + pub fn crypto_migration_id(&self) -> Result { + CryptoMigrationId::derive(self) + } +} + +impl fmt::Debug for CryptoMigrationBody { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CryptoMigrationBody") + .field("version", &self.version) + .field("account_id", &self.account_id) + .field("from_suite_id", &self.from_suite_id) + .field("to_suite", &self.to_suite) + .field("bindings", &self.bindings.as_slice()) + .field("successor_account_id", &self.successor_account_id) + .field("nonce", &"") + .field("extensions", &self.extensions) + .finish() + } +} + +impl<'de> Deserialize<'de> for CryptoMigrationBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + version: ProtocolVersion, + account_id: AccountId, + from_suite_id: CryptoSuiteId, + to_suite: CryptoSuiteDescriptor, + bindings: BoundedVec, + successor_account_id: Option, + nonce: [u8; 32], + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.version, + wire.account_id, + wire.from_suite_id, + wire.to_suite, + wire.bindings, + wire.successor_account_id, + wire.nonce, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + CryptoMigrationBody, + "cryptographic migration body bytes", + MAX_ACCOUNT_EVENT_BYTES +); + +/// Old/new cross-signature evidence for one controller binding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ControllerKeyBindingProof { + migration_id: CryptoMigrationId, + controller_id: ControllerId, + old_key_signature: AlgorithmSignature, + new_key_signature: AlgorithmSignature, +} + +impl ControllerKeyBindingProof { + /// Construct a controller's pair of migration cross-signatures. + pub fn try_new( + migration_id: CryptoMigrationId, + controller_id: ControllerId, + old_key_signature: AlgorithmSignature, + new_key_signature: AlgorithmSignature, + ) -> Result { + Self::from_canonical_wire( + migration_id, + controller_id, + old_key_signature, + new_key_signature, + ) + } + + fn from_canonical_wire( + migration_id: CryptoMigrationId, + controller_id: ControllerId, + old_key_signature: AlgorithmSignature, + new_key_signature: AlgorithmSignature, + ) -> Result { + Ok(Self { + migration_id, + controller_id, + old_key_signature, + new_key_signature, + }) + } + + /// Migration body authorized by this proof. + pub const fn migration_id(&self) -> CryptoMigrationId { + self.migration_id + } + + /// Controller whose old and new keys produced the signatures. + pub const fn controller_id(&self) -> ControllerId { + self.controller_id + } + + /// Signature produced by the previously active controller key. + pub const fn old_key_signature(&self) -> &AlgorithmSignature { + &self.old_key_signature + } + + /// Signature produced by the candidate controller key. + pub const fn new_key_signature(&self) -> &AlgorithmSignature { + &self.new_key_signature + } +} + +impl<'de> Deserialize<'de> for ControllerKeyBindingProof { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + migration_id: CryptoMigrationId, + controller_id: ControllerId, + old_key_signature: AlgorithmSignature, + new_key_signature: AlgorithmSignature, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.migration_id, + wire.controller_id, + wire.old_key_signature, + wire.new_key_signature, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + ControllerKeyBindingProof, + "controller key binding proof bytes" +); + +/// A bounded proof set sorted uniquely by controller identifier. +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct ControllerKeyBindingProofSet(BoundedVec); + +impl ControllerKeyBindingProofSet { + /// Validate, sort, and construct a complete-proof candidate set. + pub fn try_new(proofs: Vec) -> Result { + let mut proofs = BoundedVec::::new( + "controller key binding proofs", + proofs, + )? + .into_vec(); + proofs.sort_unstable_by_key(ControllerKeyBindingProof::controller_id); + Self::from_canonical_wire(BoundedVec::new("controller key binding proofs", proofs)?) + } + + fn from_canonical_wire( + proofs: BoundedVec, + ) -> Result { + if proofs.as_slice().is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "controller key binding proofs", + }); + } + for pair in proofs.as_slice().windows(2) { + if pair[0].controller_id() == pair[1].controller_id() { + return Err(IdentityError::DuplicateElement { + resource: "controller key binding proofs", + }); + } + if pair[0].controller_id() > pair[1].controller_id() { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self(proofs)) + } + + /// Canonically sorted controller binding proofs. + pub fn as_slice(&self) -> &[ControllerKeyBindingProof] { + self.0.as_slice() + } +} + +impl fmt::Debug for ControllerKeyBindingProofSet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ControllerKeyBindingProofSet") + .field(&self.0.as_slice()) + .finish() + } +} + +impl<'de> Deserialize<'de> for ControllerKeyBindingProofSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let proofs = + BoundedVec::::deserialize(deserializer)?; + Self::from_canonical_wire(proofs).map_err(de::Error::custom) + } +} + +canonical_wire!( + ControllerKeyBindingProofSet, + "controller key binding proof set bytes" +); + +/// Code-18 payload that records a candidate migration and complete cross-bindings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BeginCryptoMigration { + version: ProtocolVersion, + migration: CryptoMigrationBody, + proofs: ControllerKeyBindingProofSet, + extensions: Extensions, +} + +impl BeginCryptoMigration { + /// Construct a begin payload with one matching proof for every controller binding. + pub fn try_new( + version: ProtocolVersion, + migration: CryptoMigrationBody, + proofs: ControllerKeyBindingProofSet, + extensions: Extensions, + ) -> Result { + Self::from_canonical_wire(version, migration, proofs, extensions) + } + + fn from_canonical_wire( + version: ProtocolVersion, + migration: CryptoMigrationBody, + proofs: ControllerKeyBindingProofSet, + extensions: Extensions, + ) -> Result { + let migration_id = migration.crypto_migration_id()?; + if migration.bindings().len() != proofs.as_slice().len() { + return Err(IdentityError::InvalidRelationship { + resource: "migration binding proof coverage", + }); + } + for (binding, proof) in migration.bindings().iter().zip(proofs.as_slice()) { + if binding.controller_id() != proof.controller_id() + || proof.migration_id() != migration_id + { + return Err(IdentityError::InvalidRelationship { + resource: "migration binding proof coverage", + }); + } + if proof.new_key_signature().algorithm_code() + != migration.to_suite().signature_algorithm_code() + { + return Err(IdentityError::InvalidRelationship { + resource: "migration binding proof signature algorithm", + }); + } + } + extensions.validate_critical(&[])?; + Ok(Self { + version, + migration, + proofs, + extensions, + }) + } + + /// Schema version of this begin payload. + pub const fn version(&self) -> ProtocolVersion { + self.version + } + + /// Canonical migration body whose identifier is cross-signed. + pub const fn migration(&self) -> &CryptoMigrationBody { + &self.migration + } + + /// Complete, sorted controller cross-binding proofs. + pub const fn proofs(&self) -> &ControllerKeyBindingProofSet { + &self.proofs + } + + /// Signed extension fields, which are the payload's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for BeginCryptoMigration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + version: ProtocolVersion, + migration: CryptoMigrationBody, + proofs: ControllerKeyBindingProofSet, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire(wire.version, wire.migration, wire.proofs, wire.extensions) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + BeginCryptoMigration, + "begin cryptographic migration payload bytes", + MAX_ACCOUNT_EVENT_BYTES +); + +/// Code-19 payload that activates the old/new dual-signature phase. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ActivateCryptoMigration { + version: ProtocolVersion, + migration_id: CryptoMigrationId, + begin_event_id: EventId, + extensions: Extensions, +} + +impl ActivateCryptoMigration { + /// Construct a migration activation payload. + pub fn try_new( + version: ProtocolVersion, + migration_id: CryptoMigrationId, + begin_event_id: EventId, + extensions: Extensions, + ) -> Result { + Self::from_canonical_wire(version, migration_id, begin_event_id, extensions) + } + + fn from_canonical_wire( + version: ProtocolVersion, + migration_id: CryptoMigrationId, + begin_event_id: EventId, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + version, + migration_id, + begin_event_id, + extensions, + }) + } + + /// Schema version of this activation payload. + pub const fn version(&self) -> ProtocolVersion { + self.version + } + + /// Migration entering its dual-signature phase. + pub const fn migration_id(&self) -> CryptoMigrationId { + self.migration_id + } + + /// Event that durably began this migration. + pub const fn begin_event_id(&self) -> EventId { + self.begin_event_id + } + + /// Signed extension fields, which are the payload's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for ActivateCryptoMigration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + version: ProtocolVersion, + migration_id: CryptoMigrationId, + begin_event_id: EventId, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.version, + wire.migration_id, + wire.begin_event_id, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + ActivateCryptoMigration, + "activate cryptographic migration payload bytes", + MAX_ACCOUNT_EVENT_BYTES +); + +/// Closed code-20 action selecting candidate abort or previous-suite retirement. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum RetireCryptoSuiteMode { + /// Abort an unactivated candidate and return the account to its prior active suite. + AbortCandidate, + /// Retire the previous suite after the dual-signature activation phase. + RetirePrevious, +} + +impl RetireCryptoSuiteMode { + /// Stable v1 wire codepoint. + pub const fn code(self) -> u16 { + match self { + Self::AbortCandidate => CRYPTO_SUITE_RETIREMENT_ABORT_CANDIDATE_CODE, + Self::RetirePrevious => CRYPTO_SUITE_RETIREMENT_RETIRE_PREVIOUS_CODE, + } + } + + /// Decode one closed v1 retirement-mode codepoint. + pub const fn from_code(code: u16) -> Result { + match code { + CRYPTO_SUITE_RETIREMENT_ABORT_CANDIDATE_CODE => Ok(Self::AbortCandidate), + CRYPTO_SUITE_RETIREMENT_RETIRE_PREVIOUS_CODE => Ok(Self::RetirePrevious), + unsupported => Err(IdentityError::UnsupportedCodepoint { + registry: "cryptographic suite retirement mode", + code: unsupported, + }), + } + } +} + +impl Serialize for RetireCryptoSuiteMode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RetireCryptoSuiteMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_code(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +canonical_wire!( + RetireCryptoSuiteMode, + "cryptographic suite retirement mode bytes" +); + +/// Code-20 payload that either recovers from a failed begin or completes migration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RetireCryptoSuite { + version: ProtocolVersion, + migration_id: CryptoMigrationId, + mode: RetireCryptoSuiteMode, + phase_event_id: EventId, + successor_account_id: Option, + extensions: Extensions, +} + +impl RetireCryptoSuite { + /// Construct a recoverable code-20 migration payload. + pub fn try_new( + version: ProtocolVersion, + migration_id: CryptoMigrationId, + mode: RetireCryptoSuiteMode, + phase_event_id: EventId, + successor_account_id: Option, + extensions: Extensions, + ) -> Result { + Self::from_canonical_wire( + version, + migration_id, + mode, + phase_event_id, + successor_account_id, + extensions, + ) + } + + fn from_canonical_wire( + version: ProtocolVersion, + migration_id: CryptoMigrationId, + mode: RetireCryptoSuiteMode, + phase_event_id: EventId, + successor_account_id: Option, + extensions: Extensions, + ) -> Result { + if mode == RetireCryptoSuiteMode::AbortCandidate && successor_account_id.is_some() { + return Err(IdentityError::InvalidRelationship { + resource: "aborted migration successor account", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + version, + migration_id, + mode, + phase_event_id, + successor_account_id, + extensions, + }) + } + + /// Schema version of this code-20 payload. + pub const fn version(&self) -> ProtocolVersion { + self.version + } + + /// Migration being aborted or completed. + pub const fn migration_id(&self) -> CryptoMigrationId { + self.migration_id + } + + /// Whether this payload aborts the candidate or retires the previous suite. + pub const fn mode(&self) -> RetireCryptoSuiteMode { + self.mode + } + + /// Begin event for abort mode or activation event for retirement mode. + pub const fn phase_event_id(&self) -> EventId { + self.phase_event_id + } + + /// Optional successor account published when completing a digest-breaking migration. + pub const fn successor_account_id(&self) -> Option { + self.successor_account_id + } + + /// Signed extension fields, which are the payload's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for RetireCryptoSuite { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + version: ProtocolVersion, + migration_id: CryptoMigrationId, + mode: RetireCryptoSuiteMode, + phase_event_id: EventId, + successor_account_id: Option, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.version, + wire.migration_id, + wire.mode, + wire.phase_event_id, + wire.successor_account_id, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + RetireCryptoSuite, + "retire cryptographic suite payload bytes", + MAX_ACCOUNT_EVENT_BYTES +); + +/// Compatibility behavior required after a v1 protocol-major upgrade. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum UpgradeCompatibility { + /// Clients unable to validate the new major remain read-only. + OldClientsReadOnly, +} + +impl UpgradeCompatibility { + /// Stable v1 wire codepoint. + pub const fn code(self) -> u16 { + match self { + Self::OldClientsReadOnly => UPGRADE_COMPATIBILITY_OLD_CLIENTS_READ_ONLY_CODE, + } + } + + /// Decode one closed v1 compatibility codepoint. + pub const fn from_code(code: u16) -> Result { + match code { + UPGRADE_COMPATIBILITY_OLD_CLIENTS_READ_ONLY_CODE => Ok(Self::OldClientsReadOnly), + unsupported => Err(IdentityError::UnsupportedCodepoint { + registry: "protocol upgrade compatibility", + code: unsupported, + }), + } + } +} + +impl Serialize for UpgradeCompatibility { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for UpgradeCompatibility { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_code(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +canonical_wire!(UpgradeCompatibility, "protocol upgrade compatibility bytes"); + +/// Code-21 payload that moves an account to a strictly newer protocol major. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProtocolUpgrade { + version: ProtocolVersion, + from_major: ProtocolMajor, + to_major: ProtocolMajor, + specification_digest: Digest, + compatibility: UpgradeCompatibility, + successor_account_id: Option, + extensions: Extensions, +} + +impl ProtocolUpgrade { + /// Construct an upgrade to a strictly greater nonzero protocol major. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + version: ProtocolVersion, + from_major: ProtocolMajor, + to_major: ProtocolMajor, + specification_digest: Digest, + compatibility: UpgradeCompatibility, + successor_account_id: Option, + extensions: Extensions, + ) -> Result { + Self::from_canonical_wire( + version, + from_major, + to_major, + specification_digest, + compatibility, + successor_account_id, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_canonical_wire( + version: ProtocolVersion, + from_major: ProtocolMajor, + to_major: ProtocolMajor, + specification_digest: Digest, + compatibility: UpgradeCompatibility, + successor_account_id: Option, + extensions: Extensions, + ) -> Result { + if to_major <= from_major { + return Err(IdentityError::InvalidRelationship { + resource: "protocol upgrade major versions", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + version, + from_major, + to_major, + specification_digest, + compatibility, + successor_account_id, + extensions, + }) + } + + /// Schema version of this upgrade payload. + pub const fn version(&self) -> ProtocolVersion { + self.version + } + + /// Protocol major active before the upgrade. + pub const fn from_major(&self) -> ProtocolMajor { + self.from_major + } + + /// Strictly newer target protocol major. + pub const fn to_major(&self) -> ProtocolMajor { + self.to_major + } + + /// Digest of the target protocol specification. + pub const fn specification_digest(&self) -> Digest { + self.specification_digest + } + + /// Required behavior for clients that cannot validate the target major. + pub const fn compatibility(&self) -> UpgradeCompatibility { + self.compatibility + } + + /// Optional cross-certified account on the target protocol. + pub const fn successor_account_id(&self) -> Option { + self.successor_account_id + } + + /// Signed extension fields, which are the payload's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for ProtocolUpgrade { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + version: ProtocolVersion, + from_major: ProtocolMajor, + to_major: ProtocolMajor, + specification_digest: Digest, + compatibility: UpgradeCompatibility, + successor_account_id: Option, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.version, + wire.from_major, + wire.to_major, + wire.specification_digest, + wire.compatibility, + wire.successor_account_id, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + ProtocolUpgrade, + "protocol upgrade payload bytes", + MAX_ACCOUNT_EVENT_BYTES +); + +/// Code-22 terminal account-retirement payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RetireAccount { + version: ProtocolVersion, + successor_account_id: Option, + reason_code: Option, + extensions: Extensions, +} + +impl RetireAccount { + /// Construct terminal account retirement metadata. + pub fn try_new( + version: ProtocolVersion, + successor_account_id: Option, + reason_code: Option, + extensions: Extensions, + ) -> Result { + Self::from_canonical_wire(version, successor_account_id, reason_code, extensions) + } + + fn from_canonical_wire( + version: ProtocolVersion, + successor_account_id: Option, + reason_code: Option, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + version, + successor_account_id, + reason_code, + extensions, + }) + } + + /// Schema version of this retirement payload. + pub const fn version(&self) -> ProtocolVersion { + self.version + } + + /// Optional successor account advertised by this terminal transition. + pub const fn successor_account_id(&self) -> Option { + self.successor_account_id + } + + /// Optional nonzero public retirement reason code. + pub const fn reason_code(&self) -> Option { + self.reason_code + } + + /// Signed extension fields, which are the payload's final wire field. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for RetireAccount { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + version: ProtocolVersion, + successor_account_id: Option, + reason_code: Option, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_canonical_wire( + wire.version, + wire.successor_account_id, + wire.reason_code, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_wire!( + RetireAccount, + "retire account payload bytes", + MAX_ACCOUNT_EVENT_BYTES +); diff --git a/protocols/krikos-identity/src/device.rs b/protocols/krikos-identity/src/device.rs new file mode 100644 index 00000000000..f7fed7f7c4e --- /dev/null +++ b/protocols/krikos-identity/src/device.rs @@ -0,0 +1,774 @@ +//! Canonical device authorization and lifecycle-operation schemas. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeTuple}; + +use crate::{ + CapabilityGrant, CapabilityGrantId, DeviceDescriptor, DeviceId, Epoch, Extensions, + IdentityError, ProtocolVersion, RevocationReasonCode, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::MAX_CAPABILITIES_PER_DEVICE, + schema::BoundedVec, +}; + +macro_rules! canonical_schema { + ($name:ty, $resource:literal) => { + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +/// Closed v1 classification of an authorized application device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeviceClass { + /// Ordinary user-operated device with capabilities selected by account policy. + GeneralPurpose, + /// Device whose private keys are protected by hardware-backed storage. + HardwareBacked, + /// Low-authority device intended only for explicitly granted applications. + ApplicationOnly, + /// Unattended service device with explicitly scoped capabilities. + Service, +} + +impl DeviceClass { + /// Stable v1 codepoint. + pub const fn code(self) -> u16 { + match self { + Self::GeneralPurpose => 1, + Self::HardwareBacked => 2, + Self::ApplicationOnly => 3, + Self::Service => 4, + } + } + + /// Parse a closed v1 codepoint. + pub const fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::GeneralPurpose), + 2 => Ok(Self::HardwareBacked), + 3 => Ok(Self::ApplicationOnly), + 4 => Ok(Self::Service), + unsupported => Err(IdentityError::UnsupportedCodepoint { + registry: "device class", + code: unsupported, + }), + } + } +} + +impl Serialize for DeviceClass { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DeviceClass { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_code(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +canonical_schema!(DeviceClass, "device class bytes"); + +/// A structurally high-entropy-looking commitment to encrypted private device metadata. +/// +/// The schema rejects zero, constant, and other visibly low-diversity values. It cannot prove +/// entropy or freshness: producers must blind each commitment with independently generated +/// randomness before hashing private metadata. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct BlindedMetadataCommitment([u8; 32]); + +impl BlindedMetadataCommitment { + /// Construct a fixed-size commitment after the v1 structural entropy check. + pub fn new(bytes: [u8; 32]) -> Result { + let mut seen = [false; 256]; + let mut distinct = 0_u8; + for byte in bytes { + let index = usize::from(byte); + if !seen[index] { + seen[index] = true; + distinct = distinct + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "blinded metadata commitment byte diversity", + })?; + } + } + if distinct < 8 { + return Err(IdentityError::InvalidRelationship { + resource: "blinded metadata commitment entropy profile", + }); + } + Ok(Self(bytes)) + } + + /// Exact commitment bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for BlindedMetadataCommitment { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BlindedMetadataCommitment") + .field(&"") + .finish() + } +} + +impl<'de> Deserialize<'de> for BlindedMetadataCommitment { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(<[u8; 32]>::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +canonical_schema!( + BlindedMetadataCommitment, + "blinded metadata commitment bytes" +); + +fn validate_capabilities( + capabilities: Vec, + canonical_wire: bool, +) -> Result, IdentityError> { + if capabilities.len() > MAX_CAPABILITIES_PER_DEVICE { + return Err(IdentityError::limit( + "device capability grants", + capabilities.len(), + MAX_CAPABILITIES_PER_DEVICE, + )); + } + + let mut keyed = capabilities + .into_iter() + .map(|grant| Ok((grant.capability_grant_id()?, grant))) + .collect::, IdentityError>>()?; + if !canonical_wire { + keyed.sort_unstable_by_key(|(grant_id, _)| *grant_id); + } + for pair in keyed.windows(2) { + if pair[0].0 == pair[1].0 { + return Err(IdentityError::DuplicateElement { + resource: "device capability grants", + }); + } + if pair[0].0 > pair[1].0 { + return Err(IdentityError::NonCanonical); + } + } + BoundedVec::new( + "device capability grants", + keyed.into_iter().map(|(_, grant)| grant).collect(), + ) +} + +/// Complete public authorization installed for one independently keyed device. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeviceAuthorization { + protocol_version: ProtocolVersion, + device_id: DeviceId, + descriptor: DeviceDescriptor, + device_class: DeviceClass, + metadata_commitment: Option, + capabilities: BoundedVec, + authorization_epoch: Epoch, + extensions: Extensions, +} + +impl DeviceAuthorization { + /// Construct an authorization, sorting capabilities by their content identifier. + #[allow(clippy::too_many_arguments)] + pub fn new( + device_id: DeviceId, + descriptor: DeviceDescriptor, + device_class: DeviceClass, + metadata_commitment: Option, + capabilities: Vec, + authorization_epoch: Epoch, + extensions: Extensions, + ) -> Result { + Self::from_fields( + device_id, + descriptor, + device_class, + metadata_commitment, + capabilities, + authorization_epoch, + extensions, + false, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_fields( + device_id: DeviceId, + descriptor: DeviceDescriptor, + device_class: DeviceClass, + metadata_commitment: Option, + capabilities: Vec, + authorization_epoch: Epoch, + extensions: Extensions, + canonical_wire: bool, + ) -> Result { + if descriptor.id()? != device_id { + return Err(IdentityError::InvalidIdentifier { + resource: "device descriptor", + }); + } + let capabilities = validate_capabilities(capabilities, canonical_wire)?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + device_id, + descriptor, + device_class, + metadata_commitment, + capabilities, + authorization_epoch, + extensions, + }) + } + + /// Device identifier derived from the exact descriptor. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Independently generated public key roles bound by this authorization. + pub const fn descriptor(&self) -> &DeviceDescriptor { + &self.descriptor + } + + /// Public device class interpreted by account policy. + pub const fn device_class(&self) -> DeviceClass { + self.device_class + } + + /// Optional blinded commitment to private metadata. + pub const fn metadata_commitment(&self) -> Option { + self.metadata_commitment + } + + /// Canonically sorted, duplicate-free capability grants. + pub fn capabilities(&self) -> &[CapabilityGrant] { + self.capabilities.as_slice() + } + + /// First account epoch at which this exact authorization is active. + pub const fn authorization_epoch(&self) -> Epoch { + self.authorization_epoch + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for DeviceAuthorization { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + device_id: DeviceId, + descriptor: DeviceDescriptor, + device_class: DeviceClass, + metadata_commitment: Option, + capabilities: BoundedVec, + authorization_epoch: Epoch, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::from_fields( + wire.device_id, + wire.descriptor, + wire.device_class, + wire.metadata_commitment, + wire.capabilities.into_vec(), + wire.authorization_epoch, + wire.extensions, + true, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(DeviceAuthorization, "device authorization bytes"); + +/// Authorization-changing replacement for one device's class and capability set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeviceAuthorizationUpdate { + protocol_version: ProtocolVersion, + device_id: DeviceId, + device_class: DeviceClass, + capabilities: BoundedVec, + authorization_epoch: Epoch, + extensions: Extensions, +} + +impl DeviceAuthorizationUpdate { + /// Construct an authorization-changing update with canonical capability order. + pub fn new( + device_id: DeviceId, + device_class: DeviceClass, + capabilities: Vec, + authorization_epoch: Epoch, + extensions: Extensions, + ) -> Result { + Self::from_fields( + device_id, + device_class, + capabilities, + authorization_epoch, + extensions, + false, + ) + } + + fn from_fields( + device_id: DeviceId, + device_class: DeviceClass, + capabilities: Vec, + authorization_epoch: Epoch, + extensions: Extensions, + canonical_wire: bool, + ) -> Result { + let capabilities = validate_capabilities(capabilities, canonical_wire)?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + device_id, + device_class, + capabilities, + authorization_epoch, + extensions, + }) + } + + /// Device whose authorization changes. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Replacement public class. + pub const fn device_class(&self) -> DeviceClass { + self.device_class + } + + /// Complete replacement capability set. + pub fn capabilities(&self) -> &[CapabilityGrant] { + self.capabilities.as_slice() + } + + /// First account epoch at which this replacement is active. + pub const fn authorization_epoch(&self) -> Epoch { + self.authorization_epoch + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for DeviceAuthorizationUpdate { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + device_id: DeviceId, + device_class: DeviceClass, + capabilities: BoundedVec, + authorization_epoch: Epoch, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::from_fields( + wire.device_id, + wire.device_class, + wire.capabilities.into_vec(), + wire.authorization_epoch, + wire.extensions, + true, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!( + DeviceAuthorizationUpdate, + "device authorization update bytes" +); + +/// Metadata-commitment-only update that does not change authorization authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeviceMetadataUpdate { + protocol_version: ProtocolVersion, + device_id: DeviceId, + metadata_commitment: Option, + extensions: Extensions, +} + +impl DeviceMetadataUpdate { + /// Construct a commitment-only update. `None` clears the prior public commitment. + pub fn new( + device_id: DeviceId, + metadata_commitment: Option, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + device_id, + metadata_commitment, + extensions, + }) + } + + /// Device whose private-metadata commitment changes. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Replacement commitment, or `None` to clear it. + pub const fn metadata_commitment(&self) -> Option { + self.metadata_commitment + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for DeviceMetadataUpdate { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + device_id: DeviceId, + metadata_commitment: Option, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new(wire.device_id, wire.metadata_commitment, wire.extensions) + .map_err(de::Error::custom) + } +} + +canonical_schema!(DeviceMetadataUpdate, "device metadata update bytes"); + +/// Closed v1 split between authority-changing and metadata-only device updates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceUpdate { + /// Changes device class and/or capabilities and therefore advances account epoch. + Authorization(DeviceAuthorizationUpdate), + /// Changes only the blinded metadata commitment and does not advance account epoch. + Metadata(DeviceMetadataUpdate), +} + +impl DeviceUpdate { + /// Stable v1 update codepoint. + pub const fn code(&self) -> u16 { + match self { + Self::Authorization(_) => 1, + Self::Metadata(_) => 2, + } + } +} + +impl Serialize for DeviceUpdate { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut tuple = serializer.serialize_tuple(2)?; + tuple.serialize_element(&self.code())?; + match self { + Self::Authorization(update) => tuple.serialize_element(update)?, + Self::Metadata(update) => tuple.serialize_element(update)?, + } + tuple.end() + } +} + +impl<'de> Deserialize<'de> for DeviceUpdate { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = DeviceUpdate; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a closed v1 device update") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => Ok(DeviceUpdate::Authorization( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )), + 2 => Ok(DeviceUpdate::Metadata( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "device update", + code: unsupported, + })), + } + } + } + + deserializer.deserialize_tuple(2, Visitor) + } +} + +canonical_schema!(DeviceUpdate, "device update bytes"); + +macro_rules! simple_device_operation { + ($name:ident, $resource:literal, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] + pub struct $name { + protocol_version: ProtocolVersion, + device_id: DeviceId, + extensions: Extensions, + } + + impl $name { + /// Construct this exact device lifecycle operation. + pub fn new(device_id: DeviceId, extensions: Extensions) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + device_id, + extensions, + }) + } + + /// Device affected by this operation. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + device_id: DeviceId, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new(wire.device_id, wire.extensions).map_err(de::Error::custom) + } + } + + canonical_schema!($name, $resource); + }; +} + +simple_device_operation!( + SuspendDevice, + "suspend device operation bytes", + "Temporarily disable one active device without erasing its authorization." +); +simple_device_operation!( + ReinstateDevice, + "reinstate device operation bytes", + "Restore one suspended device's existing authorization." +); + +/// Permanently revoke one device with an optional public reason category. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RevokeDevice { + protocol_version: ProtocolVersion, + device_id: DeviceId, + reason_code: Option, + extensions: Extensions, +} + +impl RevokeDevice { + /// Construct a permanent device revocation payload. + pub fn new( + device_id: DeviceId, + reason_code: Option, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + device_id, + reason_code, + extensions, + }) + } + + /// Device permanently revoked. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Optional nonzero public reason category. Private detail remains encrypted metadata. + pub const fn reason_code(&self) -> Option { + self.reason_code + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for RevokeDevice { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + device_id: DeviceId, + reason_code: Option, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new(wire.device_id, wire.reason_code, wire.extensions).map_err(de::Error::custom) + } +} + +canonical_schema!(RevokeDevice, "revoke device operation bytes"); + +/// Atomic replacement of one old device identity with a complete new authorization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RotateDeviceKeys { + protocol_version: ProtocolVersion, + old_device_id: DeviceId, + new_authorization: DeviceAuthorization, + extensions: Extensions, +} + +impl RotateDeviceKeys { + /// Construct an atomic old-device revocation and new-device authorization. + pub fn new( + old_device_id: DeviceId, + new_authorization: DeviceAuthorization, + extensions: Extensions, + ) -> Result { + if old_device_id == new_authorization.device_id() { + return Err(IdentityError::InvalidRelationship { + resource: "device key rotation identifiers", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + old_device_id, + new_authorization, + extensions, + }) + } + + /// Previously active device identity revoked by the rotation. + pub const fn old_device_id(&self) -> DeviceId { + self.old_device_id + } + + /// Complete replacement authorization installed atomically. + pub const fn new_authorization(&self) -> &DeviceAuthorization { + &self.new_authorization + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for RotateDeviceKeys { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + old_device_id: DeviceId, + new_authorization: DeviceAuthorization, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new(wire.old_device_id, wire.new_authorization, wire.extensions) + .map_err(de::Error::custom) + } +} + +canonical_schema!(RotateDeviceKeys, "rotate device keys operation bytes"); diff --git a/protocols/krikos-identity/src/error.rs b/protocols/krikos-identity/src/error.rs new file mode 100644 index 00000000000..81757d68b83 --- /dev/null +++ b/protocols/krikos-identity/src/error.rs @@ -0,0 +1,319 @@ +//! Stable identity-protocol error classes. + +use std::fmt; + +/// Kind of algorithm registry entry that failed validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AlgorithmKind { + /// Cryptographic hash algorithm. + Hash, + /// Digital-signature algorithm. + Signature, + /// Key-agreement algorithm. + Agreement, + /// Key-derivation algorithm. + Kdf, + /// Authenticated-encryption algorithm. + Aead, +} + +impl fmt::Display for AlgorithmKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Hash => "hash", + Self::Signature => "signature", + Self::Agreement => "agreement", + Self::Kdf => "key derivation", + Self::Aead => "authenticated encryption", + }; + formatter.write_str(name) + } +} + +/// Error returned by identity protocol validation and foundational codecs. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum IdentityError { + /// Bytes are not a well-formed v1 protocol object. + #[error("invalid canonical protocol encoding")] + InvalidEncoding, + /// The encoded object uses a protocol version this implementation cannot verify. + #[error("unsupported protocol version {version}")] + UnsupportedVersion { + /// Unsupported wire version. + version: u16, + }, + /// The encoded object uses an unknown cryptographic algorithm codepoint. + #[error("unsupported {kind} algorithm codepoint {code}")] + UnsupportedAlgorithm { + /// Registry containing the codepoint. + kind: AlgorithmKind, + /// Unsupported wire codepoint. + code: u16, + }, + /// A wire codepoint is intentionally reserved and cannot be accepted as state. + #[error("reserved {registry} codepoint {code}")] + ReservedCodepoint { + /// Registry containing the reserved value. + registry: &'static str, + /// Reserved codepoint encountered on the wire. + code: u16, + }, + /// A wire codepoint is unknown in a closed v1 registry. + #[error("unsupported {registry} codepoint {code}")] + UnsupportedCodepoint { + /// Registry containing the unknown value. + registry: &'static str, + /// Unknown codepoint encountered on the wire. + code: u16, + }, + /// A public key is malformed or forbidden by its algorithm profile. + #[error("invalid {kind} public key")] + InvalidPublicKey { + /// Registry containing the key algorithm. + kind: AlgorithmKind, + }, + /// Extension code zero is reserved for the absence of an extension. + #[error("invalid extension code {code}")] + InvalidExtensionCode { + /// Invalid extension code. + code: u32, + }, + /// An extension code appears more than once in one object. + #[error("duplicate extension code {code}")] + DuplicateExtension { + /// Repeated extension code. + code: u32, + }, + /// A critical extension is not understood by the validating object. + #[error("unknown critical extension code {code}")] + UnknownCriticalExtension { + /// Unknown critical extension code. + code: u32, + }, + /// Bytes decode but are not the unique canonical representation. + #[error("non-canonical protocol encoding")] + NonCanonical, + /// A named input or collection exceeds a protocol resource bound. + #[error("{resource} contains {actual} items or bytes, maximum is {maximum}")] + LimitExceeded { + /// Name of the bounded resource. + resource: &'static str, + /// Observed item or byte count. + actual: usize, + /// Maximum accepted item or byte count. + maximum: usize, + }, + /// A collection required by the schema is empty. + #[error("{resource} must not be empty")] + EmptyCollection { + /// Name of the empty resource. + resource: &'static str, + }, + /// A set-like collection contains the same semantic element more than once. + #[error("{resource} contains a duplicate element")] + DuplicateElement { + /// Name of the duplicate-bearing resource. + resource: &'static str, + }, + /// A typed content identifier is malformed or inconsistent with its body. + #[error("invalid {resource} identifier")] + InvalidIdentifier { + /// Identifier class. + resource: &'static str, + }, + /// Two otherwise valid fields have a forbidden relationship. + #[error("invalid {resource} relationship")] + InvalidRelationship { + /// Relationship class. + resource: &'static str, + }, + /// A schema field that must be nonzero was zero. + #[error("{resource} must be nonzero")] + ZeroValue { + /// Nonzero field class. + resource: &'static str, + }, + /// A threshold cannot be met by its eligible authority set. + #[error("authorization threshold is not satisfiable")] + UnsatisfiableThreshold, + /// One public signing key was assigned to multiple active authorities. + #[error("duplicate active signing key")] + DuplicateSigningKey, + /// A control, recovery, or provider policy is structurally invalid. + #[error("invalid {resource} policy")] + InvalidPolicy { + /// Policy class. + resource: &'static str, + }, + /// A capability grant is structurally invalid. + #[error("invalid capability: {reason}")] + InvalidCapability { + /// Stable validation reason. + reason: &'static str, + }, + /// A capability delegation is structurally invalid. + #[error("invalid delegation: {reason}")] + InvalidDelegation { + /// Stable validation reason. + reason: &'static str, + }, + /// A protocol feature is intentionally not accepted by v1 policy semantics. + #[error("unsupported v1 policy feature: {feature}")] + UnsupportedPolicyFeature { + /// Feature deliberately unavailable in v1. + feature: &'static str, + }, + /// A cryptographic signature did not verify under its declared key and suite. + #[error("invalid protocol signature")] + InvalidSignature, + /// A group-key wrap names a suite other than the fixed v1 KEM/KDF/AEAD profile. + #[error("unsupported group-key wrap cryptographic suite")] + UnsupportedKeyWrapSuite, + /// Group-key unwrap failed authentication. + /// + /// This deliberately does not distinguish a wrong key, modified ciphertext, or + /// modified associated data. Retrying the same inputs cannot succeed. + #[error("group-key wrap authentication failed")] + KeyWrapAuthenticationFailed, + /// Private artifact authentication failed. + /// + /// This deliberately does not distinguish a wrong key or passphrase, modified ciphertext, + /// modified parameters, or modified associated context. + #[error("private artifact authentication failed")] + PrivateArtifactAuthenticationFailed, + /// The operating system could not provide cryptographic entropy. + /// + /// A caller may retry after the platform entropy source becomes available. + #[error("operating-system cryptographic entropy is unavailable")] + EntropyUnavailable, + /// An approval names no controller in the relevant pre-state. + #[error("unknown account controller")] + UnknownController, + /// A known controller is outside the operation rule's selector or immutable scope. + #[error("controller is ineligible for this operation")] + IneligibleController, + /// A terminally removed controller attempted to authorize a later transition. + #[error("controller has been revoked")] + RevokedController, + /// The same authority was counted more than once in one authorization set. + #[error("duplicate authorization signer")] + DuplicateSigner, + /// Valid eligible signatures do not meet the active policy threshold. + #[error("account authorization denied")] + AuthorizationDenied, + /// An event sequence does not advance from its predecessor exactly once. + #[error("invalid account event sequence")] + InvalidSequence, + /// A possible branch predates the bounded in-memory lineage cache and needs durable replay. + #[error("historical account state is required for event sequence {sequence}")] + HistoricalStateRequired { + /// Sequence whose authenticated pre-state must be loaded from durable lineage. + sequence: u64, + }, + /// An event or application envelope names the wrong security epoch. + #[error("invalid account epoch")] + InvalidEpoch, + /// An event's predecessor reference does not match the complete current head set. + #[error("invalid account event predecessor")] + InvalidPredecessor, + /// A nested object belongs to a different stable account. + #[error("account identifier mismatch")] + AccountMismatch, + /// Conflicting valid account-control bodies were retained as a fork. + #[error("account fork detected")] + ForkDetected, + /// The requested transition is forbidden while the account remains forked. + #[error("account is forked")] + AccountForked, + /// The requested transition conflicts with an authoritative pending recovery. + #[error("account recovery is pending")] + RecoveryPending, + /// The account is terminally retired. + #[error("account is retired")] + AccountRetired, + /// A newer protocol major is authoritative, so this v1 client is read-only. + #[error("account is read-only after a protocol-major upgrade")] + ProtocolUpgradeReadOnly, + /// A rule requires signed provider freshness that is not available. + #[error("required freshness evidence is unavailable")] + FreshnessUnavailable, + /// Signed provider evidence is older than the applicable freshness bound. + #[error("freshness evidence is stale")] + StaleEvidence, + /// A delayed transition has not reached its signed provider-observed deadline. + #[error("required authorization delay has not elapsed")] + DelayNotElapsed, + /// Historical evidence names a policy revision other than the applicable pre-state revision. + #[error("policy version mismatch")] + PolicyVersionMismatch, + /// A device has no authorization in the supplied account state. + #[error("device is not authorized")] + DeviceNotAuthorized, + /// A temporarily suspended device attempted an authorized action. + #[error("device is suspended")] + DeviceSuspended, + /// A terminally revoked device attempted an authorized action. + #[error("device is revoked")] + DeviceRevoked, + /// An atomic state write raced with a different account revision. + #[error("stale account-store revision")] + StaleRevision, + /// Durable bytes or indices are inconsistent with their authenticated state. + #[error("identity store corruption")] + StorageCorruption, + /// An owned operation was explicitly cancelled before completion. + #[error("identity operation cancelled")] + Cancelled, + /// A bounded retry policy exhausted all permitted attempts. + #[error("identity operation exhausted its retry budget")] + RetryExhausted, + /// Bounded concurrency or queue capacity is currently exhausted. + #[error("identity resource is busy")] + ResourceBusy, + /// A configured transparency provider could not be reached. + #[error("transparency provider is unavailable")] + ProviderUnavailable, + /// A configured transparency-provider request exceeded its bounded deadline. + #[error("transparency provider request timed out")] + ProviderTimeout, + /// A transparency provider rejected work because its abuse-control limit was reached. + #[error("transparency provider rate limit exceeded")] + ProviderRateLimited, + /// A sealed provider generation released the requested payload to its verified archive. + #[error("provider archive is required for this released history")] + ProviderArchiveRequired, + /// Protected application writes are blocked pending mandatory key rotation. + #[error("protected writes are blocked pending group-key rotation")] + ProtectedWritesBlocked, + /// A Merkle, consistency, non-membership, or lineage proof is invalid. + #[error("invalid transparency proof")] + InvalidProof, + /// Two signed provider heads prove inconsistent statements for one tree size. + #[error("transparency provider equivocation")] + ProviderEquivocation, + /// A provider returned an older tree size or observation than a previously verified head. + #[error("transparency provider rollback")] + ProviderRollback, + /// A protocol counter or time calculation overflowed. + #[error("{resource} arithmetic overflow")] + ArithmeticOverflow { + /// Counter or unit that overflowed. + resource: &'static str, + }, +} + +impl IdentityError { + pub(crate) const fn unsupported_algorithm(kind: AlgorithmKind, code: u16) -> Self { + Self::UnsupportedAlgorithm { kind, code } + } + + pub(crate) const fn limit(resource: &'static str, actual: usize, maximum: usize) -> Self { + Self::LimitExceeded { + resource, + actual, + maximum, + } + } +} diff --git a/protocols/krikos-identity/src/event.rs b/protocols/krikos-identity/src/event.rs new file mode 100644 index 00000000000..355691635c4 --- /dev/null +++ b/protocols/krikos-identity/src/event.rs @@ -0,0 +1,1909 @@ +//! Account-event admission and mergeable controller approval schemas. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeTuple}; + +use crate::{ + AccountId, ActivateCryptoMigration, AdmissionEvidenceId, AlgorithmSignature, + BeginCryptoMigration, BeginRecovery, CancelRecovery, CheckpointId, ControlPolicy, + ControllerApprovalId, ControllerDescriptor, ControllerId, ControllerKeyId, CryptoSuiteId, + DeviceAuthorization, DeviceAuthorizationUpdate, DeviceMetadataUpdate, Epoch, + EventAuthorizationId, EventId, EventIntentApprovalId, Extensions, FinalizeRecovery, + GenesisAnchor, IdentityError, OperationKind, ProposalId, ProtocolUpgrade, ProtocolVersion, + ProviderLogSubject, ProviderPolicy, ProviderPolicyId, ProviderReceipts, RecoveryPolicy, + ReinstateDevice, ResolveFork, RetireAccount, RetireCryptoSuite, RevokeDevice, RotateDeviceKeys, + Sequence, SuspendDevice, Timestamp, VetoRecovery, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{ + MAX_ACCOUNT_EVENT_BYTES, MAX_ACTIVE_CRYPTO_SUITES, MAX_AUTHORIZATION_SIGNATURES, + MAX_FORK_HEADS, + }, + schema::BoundedVec, + types::{HashDomain, hash_bytes}, +}; + +macro_rules! canonical_schema { + ($name:ty, $resource:literal) => { + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum EventPredecessorsKind { + Genesis(GenesisAnchor), + Events(BoundedVec), +} + +/// Complete predecessor reference for one account event. +/// +/// The first event names the genesis anchor. Linear events name one event ID, while +/// fork resolution names the complete bounded, sorted set of current branch heads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventPredecessors(EventPredecessorsKind); + +impl EventPredecessors { + /// Construct the predecessor of the first account event. + pub const fn genesis(anchor: GenesisAnchor) -> Self { + Self(EventPredecessorsKind::Genesis(anchor)) + } + + /// Sort and construct a nonempty, duplicate-free event-head set. + pub fn events(mut event_ids: Vec) -> Result { + event_ids.sort_unstable(); + Self::events_from_sorted(event_ids) + } + + fn events_from_sorted(event_ids: Vec) -> Result { + if event_ids.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "event predecessors", + }); + } + let event_ids = BoundedVec::new("event predecessors", event_ids)?; + for pair in event_ids.as_slice().windows(2) { + if pair[0] == pair[1] { + return Err(IdentityError::DuplicateElement { + resource: "event predecessors", + }); + } + if pair[0] > pair[1] { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self(EventPredecessorsKind::Events(event_ids))) + } + + /// Genesis anchor when this is the first-event predecessor. + pub const fn genesis_anchor(&self) -> Option { + match &self.0 { + EventPredecessorsKind::Genesis(anchor) => Some(*anchor), + EventPredecessorsKind::Events(_) => None, + } + } + + /// Complete event-head set when this names existing events. + pub fn event_heads(&self) -> Option<&[EventId]> { + match &self.0 { + EventPredecessorsKind::Genesis(_) => None, + EventPredecessorsKind::Events(event_ids) => Some(event_ids.as_slice()), + } + } +} + +impl Serialize for EventPredecessors { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.0 { + EventPredecessorsKind::Genesis(anchor) => (1u16, anchor).serialize(serializer), + EventPredecessorsKind::Events(event_ids) => (2u16, event_ids).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for EventPredecessors { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = EventPredecessors; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a v1 event predecessor reference") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => Ok(EventPredecessors::genesis( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )), + 2 => { + let values = sequence + .next_element::>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + EventPredecessors::events_from_sorted(values.into_vec()) + .map_err(de::Error::custom) + } + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "event predecessors", + code: unsupported, + })), + } + } + } + + deserializer.deserialize_tuple(2, Visitor) + } +} + +impl CanonicalCodec for EventPredecessors { + const RESOURCE: &'static str = "event predecessor bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let (code, _) = + postcard::take_from_bytes::(bytes).map_err(|_| IdentityError::InvalidEncoding)?; + match code { + 1 => { + let (_, anchor): (u16, GenesisAnchor) = decode_wire(bytes)?; + Ok(Self::genesis(anchor)) + } + 2 => { + let (_, values): (u16, BoundedVec) = decode_wire(bytes)?; + Self::events_from_sorted(values.into_vec()) + } + unsupported => Err(IdentityError::UnsupportedCodepoint { + registry: "event predecessors", + code: unsupported, + }), + } + } +} + +/// Complete closed v1 account-control operation registry with typed payloads. +#[allow(clippy::large_enum_variant)] // Exact typed wire payloads avoid hidden heap indirection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountOperation { + /// Authorize a new independently keyed device. + AuthorizeDevice(DeviceAuthorization), + /// Replace a device's class or capability authorization. + UpdateDeviceAuthorization(DeviceAuthorizationUpdate), + /// Replace only a device's blinded private-metadata commitment. + UpdateDeviceMetadata(DeviceMetadataUpdate), + /// Temporarily disable a device. + SuspendDevice(SuspendDevice), + /// Restore a suspended device. + ReinstateDevice(ReinstateDevice), + /// Permanently revoke a device identifier. + RevokeDevice(RevokeDevice), + /// Atomically replace a device with newly generated keys. + RotateDeviceKeys(RotateDeviceKeys), + /// Add one independently keyed account controller. + AddController(ControllerDescriptor), + /// Permanently remove one controller identifier. + RemoveController(ControllerId), + /// Replace the weighted account-control policy. + ChangeControlPolicy(ControlPolicy), + /// Replace the explicit recovery policy. + ChangeRecoveryPolicy(RecoveryPolicy), + /// Replace the minimum transparency-provider policy. + ChangeProviderPolicy(ProviderPolicy), + /// Install one authoritative pending recovery. + BeginRecovery(BeginRecovery), + /// Veto the exact pending recovery under the pre-recovery control policy. + VetoRecovery(VetoRecovery), + /// Cancel the exact pending recovery under its pre-state recovery policy. + CancelRecovery(CancelRecovery), + /// Finalize a sufficiently authorized and delayed recovery. + FinalizeRecovery(FinalizeRecovery), + /// Select one existing branch and add only monotonic revocations. + ResolveFork(ResolveFork), + /// Begin a cross-signed controller signature-suite migration. + BeginCryptoMigration(BeginCryptoMigration), + /// Activate the dual-signature migration phase. + ActivateCryptoMigration(ActivateCryptoMigration), + /// Abort a candidate or retire the previous cryptographic suite. + RetireCryptoSuite(RetireCryptoSuite), + /// Adopt a future account protocol major under explicit compatibility rules. + UpgradeProtocol(ProtocolUpgrade), + /// Terminally retire the account. + RetireAccount(RetireAccount), +} + +impl AccountOperation { + /// Stable operation class used for policy selection and wire codepoints. + pub const fn kind(&self) -> OperationKind { + match self { + Self::AuthorizeDevice(_) => OperationKind::AuthorizeDevice, + Self::UpdateDeviceAuthorization(_) => OperationKind::UpdateDeviceAuthorization, + Self::UpdateDeviceMetadata(_) => OperationKind::UpdateDeviceMetadata, + Self::SuspendDevice(_) => OperationKind::SuspendDevice, + Self::ReinstateDevice(_) => OperationKind::ReinstateDevice, + Self::RevokeDevice(_) => OperationKind::RevokeDevice, + Self::RotateDeviceKeys(_) => OperationKind::RotateDeviceKeys, + Self::AddController(_) => OperationKind::AddController, + Self::RemoveController(_) => OperationKind::RemoveController, + Self::ChangeControlPolicy(_) => OperationKind::ChangeControlPolicy, + Self::ChangeRecoveryPolicy(_) => OperationKind::ChangeRecoveryPolicy, + Self::ChangeProviderPolicy(_) => OperationKind::ChangeProviderPolicy, + Self::BeginRecovery(_) => OperationKind::BeginRecovery, + Self::VetoRecovery(_) => OperationKind::VetoRecovery, + Self::CancelRecovery(_) => OperationKind::CancelRecovery, + Self::FinalizeRecovery(_) => OperationKind::FinalizeRecovery, + Self::ResolveFork(_) => OperationKind::ResolveFork, + Self::BeginCryptoMigration(_) => OperationKind::BeginCryptoMigration, + Self::ActivateCryptoMigration(_) => OperationKind::ActivateCryptoMigration, + Self::RetireCryptoSuite(_) => OperationKind::RetireCryptoSuite, + Self::UpgradeProtocol(_) => OperationKind::UpgradeProtocol, + Self::RetireAccount(_) => OperationKind::RetireAccount, + } + } +} + +impl Serialize for AccountOperation { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut tuple = serializer.serialize_tuple(2)?; + tuple.serialize_element(&self.kind().code())?; + match self { + Self::AuthorizeDevice(payload) => tuple.serialize_element(payload)?, + Self::UpdateDeviceAuthorization(payload) => tuple.serialize_element(payload)?, + Self::UpdateDeviceMetadata(payload) => tuple.serialize_element(payload)?, + Self::SuspendDevice(payload) => tuple.serialize_element(payload)?, + Self::ReinstateDevice(payload) => tuple.serialize_element(payload)?, + Self::RevokeDevice(payload) => tuple.serialize_element(payload)?, + Self::RotateDeviceKeys(payload) => tuple.serialize_element(payload)?, + Self::AddController(payload) => tuple.serialize_element(payload)?, + Self::RemoveController(payload) => tuple.serialize_element(payload)?, + Self::ChangeControlPolicy(payload) => tuple.serialize_element(payload)?, + Self::ChangeRecoveryPolicy(payload) => tuple.serialize_element(payload)?, + Self::ChangeProviderPolicy(payload) => tuple.serialize_element(payload)?, + Self::BeginRecovery(payload) => tuple.serialize_element(payload)?, + Self::VetoRecovery(payload) => tuple.serialize_element(payload)?, + Self::CancelRecovery(payload) => tuple.serialize_element(payload)?, + Self::FinalizeRecovery(payload) => tuple.serialize_element(payload)?, + Self::ResolveFork(payload) => tuple.serialize_element(payload)?, + Self::BeginCryptoMigration(payload) => tuple.serialize_element(payload)?, + Self::ActivateCryptoMigration(payload) => tuple.serialize_element(payload)?, + Self::RetireCryptoSuite(payload) => tuple.serialize_element(payload)?, + Self::UpgradeProtocol(payload) => tuple.serialize_element(payload)?, + Self::RetireAccount(payload) => tuple.serialize_element(payload)?, + } + tuple.end() + } +} + +impl<'de> Deserialize<'de> for AccountOperation { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = AccountOperation; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a closed typed v1 account operation") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let kind = OperationKind::from_code(code).map_err(de::Error::custom)?; + macro_rules! payload { + ($variant:ident, $type:ty) => { + AccountOperation::$variant( + sequence + .next_element::<$type>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + ) + }; + } + Ok(match kind { + OperationKind::AuthorizeDevice => { + payload!(AuthorizeDevice, DeviceAuthorization) + } + OperationKind::UpdateDeviceAuthorization => { + payload!(UpdateDeviceAuthorization, DeviceAuthorizationUpdate) + } + OperationKind::UpdateDeviceMetadata => { + payload!(UpdateDeviceMetadata, DeviceMetadataUpdate) + } + OperationKind::SuspendDevice => payload!(SuspendDevice, SuspendDevice), + OperationKind::ReinstateDevice => payload!(ReinstateDevice, ReinstateDevice), + OperationKind::RevokeDevice => payload!(RevokeDevice, RevokeDevice), + OperationKind::RotateDeviceKeys => payload!(RotateDeviceKeys, RotateDeviceKeys), + OperationKind::AddController => payload!(AddController, ControllerDescriptor), + OperationKind::RemoveController => payload!(RemoveController, ControllerId), + OperationKind::ChangeControlPolicy => { + payload!(ChangeControlPolicy, ControlPolicy) + } + OperationKind::ChangeRecoveryPolicy => { + payload!(ChangeRecoveryPolicy, RecoveryPolicy) + } + OperationKind::ChangeProviderPolicy => { + payload!(ChangeProviderPolicy, ProviderPolicy) + } + OperationKind::BeginRecovery => payload!(BeginRecovery, BeginRecovery), + OperationKind::VetoRecovery => payload!(VetoRecovery, VetoRecovery), + OperationKind::CancelRecovery => payload!(CancelRecovery, CancelRecovery), + OperationKind::FinalizeRecovery => payload!(FinalizeRecovery, FinalizeRecovery), + OperationKind::ResolveFork => payload!(ResolveFork, ResolveFork), + OperationKind::BeginCryptoMigration => { + payload!(BeginCryptoMigration, BeginCryptoMigration) + } + OperationKind::ActivateCryptoMigration => { + payload!(ActivateCryptoMigration, ActivateCryptoMigration) + } + OperationKind::RetireCryptoSuite => { + payload!(RetireCryptoSuite, RetireCryptoSuite) + } + OperationKind::UpgradeProtocol => payload!(UpgradeProtocol, ProtocolUpgrade), + OperationKind::RetireAccount => payload!(RetireAccount, RetireAccount), + }) + } + } + + deserializer.deserialize_tuple(2, Visitor) + } +} + +impl CanonicalCodec for AccountOperation { + const RESOURCE: &'static str = "account operation bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let (code, _) = + postcard::take_from_bytes::(bytes).map_err(|_| IdentityError::InvalidEncoding)?; + let _ = OperationKind::from_code(code)?; + decode_wire(bytes) + } +} + +/// Canonical unsigned account-event body used by both proposal and event ID domains. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EventBody { + protocol_version: ProtocolVersion, + account_id: AccountId, + sequence: Sequence, + resulting_epoch: Epoch, + predecessors: EventPredecessors, + operation: AccountOperation, + created_at: Timestamp, + nonce: [u8; 16], + extensions: Extensions, +} + +/// Circularity-free material naming one admitted event history. +#[derive(Serialize)] +struct AdmittedEventIdentity<'a> { + body: &'a EventBody, + admission_evidence_id: AdmissionEvidenceId, +} + +impl EventBody { + /// Construct a structurally valid v1 body; state-dependent checks happen in projection. + #[allow(clippy::too_many_arguments)] + pub fn new( + account_id: AccountId, + sequence: Sequence, + resulting_epoch: Epoch, + predecessors: EventPredecessors, + operation: AccountOperation, + created_at: Timestamp, + nonce: [u8; 16], + extensions: Extensions, + ) -> Result { + if sequence == Sequence::GENESIS { + return Err(IdentityError::InvalidSequence); + } + if nonce == [0; 16] { + return Err(IdentityError::ZeroValue { + resource: "account event nonce", + }); + } + if sequence.get() == 1 { + if predecessors.genesis_anchor().is_none() { + return Err(IdentityError::InvalidPredecessor); + } + } else if predecessors.event_heads().is_none() { + return Err(IdentityError::InvalidPredecessor); + } + + match &operation { + AccountOperation::ResolveFork(resolution) => { + if resolution.fork().account_id() != account_id + || predecessors.event_heads() != Some(resolution.fork().heads()) + { + return Err(IdentityError::InvalidPredecessor); + } + } + AccountOperation::BeginRecovery(begin) => { + if begin.proposal().plan().account_id() != account_id { + return Err(IdentityError::AccountMismatch); + } + let prior_event_head = begin.proposal().plan().prior_event_head(); + if sequence.get() == 1 + || predecessors.event_heads() != Some(std::slice::from_ref(&prior_event_head)) + { + return Err(IdentityError::InvalidPredecessor); + } + } + AccountOperation::BeginCryptoMigration(begin) => { + if begin.migration().account_id() != account_id { + return Err(IdentityError::AccountMismatch); + } + if predecessors + .event_heads() + .is_some_and(|heads| heads.len() != 1) + { + return Err(IdentityError::InvalidPredecessor); + } + } + _ => { + if predecessors + .event_heads() + .is_some_and(|heads| heads.len() != 1) + { + return Err(IdentityError::InvalidPredecessor); + } + } + } + extensions.validate_critical(&[])?; + let body = Self { + protocol_version: ProtocolVersion::V1, + account_id, + sequence, + resulting_epoch, + predecessors, + operation, + created_at, + nonce, + extensions, + }; + let encoded_len = encode_wire(&body)?.len(); + if encoded_len > MAX_ACCOUNT_EVENT_BYTES { + return Err(IdentityError::limit( + "account event body bytes", + encoded_len, + MAX_ACCOUNT_EVENT_BYTES, + )); + } + Ok(body) + } + + /// Stable account whose authority log contains this body. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact account sequence proposed by this body. + pub const fn sequence(&self) -> Sequence { + self.sequence + } + + /// Account security epoch after this operation is applied. + pub const fn resulting_epoch(&self) -> Epoch { + self.resulting_epoch + } + + /// Complete predecessor reference used for fork detection and resolution. + pub const fn predecessors(&self) -> &EventPredecessors { + &self.predecessors + } + + /// Typed authoritative operation. + pub const fn operation(&self) -> &AccountOperation { + &self.operation + } + + /// Metadata timestamp; never ordering or authority input. + pub const fn created_at(&self) -> Timestamp { + self.created_at + } + + /// Proposal-domain body identifier signed before provider delay observation. + pub fn proposal_id(&self) -> Result { + ProposalId::derive(self) + } + + /// Final event identifier for this body under one exact admission history. + pub fn admitted_event_id( + &self, + admission_evidence_id: AdmissionEvidenceId, + ) -> Result { + let material = AdmittedEventIdentity { + body: self, + admission_evidence_id, + }; + let bytes = encode_wire(&material)?; + Ok(EventId::from_digest(hash_bytes( + HashDomain::AccountEvent, + &bytes, + ))) + } +} + +impl<'de> Deserialize<'de> for EventBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + sequence: Sequence, + resulting_epoch: Epoch, + predecessors: EventPredecessors, + operation: AccountOperation, + created_at: Timestamp, + nonce: [u8; 16], + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new( + wire.account_id, + wire.sequence, + wire.resulting_epoch, + wire.predecessors, + wire.operation, + wire.created_at, + wire.nonce, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for EventBody { + const RESOURCE: &'static str = "account event body bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// One signature tied to an exact controller key and crypto suite. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyedSignature { + crypto_suite_id: CryptoSuiteId, + controller_key_id: ControllerKeyId, + signature: AlgorithmSignature, +} + +impl KeyedSignature { + /// Construct a keyed controller signature. + pub const fn new( + crypto_suite_id: CryptoSuiteId, + controller_key_id: ControllerKeyId, + signature: AlgorithmSignature, + ) -> Self { + Self { + crypto_suite_id, + controller_key_id, + signature, + } + } + + /// Cryptographic suite under which this signature must verify. + pub const fn crypto_suite_id(&self) -> CryptoSuiteId { + self.crypto_suite_id + } + + /// Exact controller key expected to verify this signature. + pub const fn controller_key_id(&self) -> ControllerKeyId { + self.controller_key_id + } + + /// Algorithm-tagged signature bytes. + pub const fn signature(&self) -> &AlgorithmSignature { + &self.signature + } + + const fn sort_key(&self) -> (CryptoSuiteId, ControllerKeyId) { + (self.crypto_suite_id, self.controller_key_id) + } +} + +canonical_schema!(KeyedSignature, "keyed controller signature bytes"); + +/// Body signed before providers observe a delayed proposal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EventIntentApprovalBody { + protocol_version: ProtocolVersion, + controller_id: ControllerId, + proposal_id: ProposalId, + extensions: Extensions, +} + +impl EventIntentApprovalBody { + /// Construct one exact proposal-intent approval body. + pub fn new( + controller_id: ControllerId, + proposal_id: ProposalId, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + controller_id, + proposal_id, + extensions, + }) + } + + /// Controller making this approval. + pub const fn controller_id(&self) -> ControllerId { + self.controller_id + } + + /// Proposal whose delay is being started. + pub const fn proposal_id(&self) -> ProposalId { + self.proposal_id + } + + /// Derive the exact signed approval-body identifier. + pub fn event_intent_approval_id(&self) -> Result { + EventIntentApprovalId::derive(self) + } +} + +impl<'de> Deserialize<'de> for EventIntentApprovalBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + controller_id: ControllerId, + proposal_id: ProposalId, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new(wire.controller_id, wire.proposal_id, wire.extensions).map_err(de::Error::custom) + } +} + +canonical_schema!(EventIntentApprovalBody, "event intent approval body bytes"); + +/// Mergeable signatures from one controller over one proposal intent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedEventIntentApproval { + body: EventIntentApprovalBody, + signatures: BoundedVec, +} + +impl SignedEventIntentApproval { + /// Construct sorted, duplicate-free suite signatures for one controller. + pub fn new( + body: EventIntentApprovalBody, + mut signatures: Vec, + ) -> Result { + signatures.sort_unstable_by_key(KeyedSignature::sort_key); + Self::from_sorted(body, signatures) + } + + fn from_sorted( + body: EventIntentApprovalBody, + signatures: Vec, + ) -> Result { + if signatures.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "event intent signatures", + }); + } + let signatures = BoundedVec::new("event intent signatures", signatures)?; + for pair in signatures.as_slice().windows(2) { + if pair[0].sort_key() == pair[1].sort_key() { + return Err(IdentityError::DuplicateElement { + resource: "event intent signatures", + }); + } + if pair[0].sort_key() > pair[1].sort_key() { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self { body, signatures }) + } + + /// Signed intent body. + pub const fn body(&self) -> &EventIntentApprovalBody { + &self.body + } + + /// Sorted suite signatures over the canonical intent body. + pub fn signatures(&self) -> &[KeyedSignature] { + self.signatures.as_slice() + } +} + +impl<'de> Deserialize<'de> for SignedEventIntentApproval { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + body: EventIntentApprovalBody, + signatures: BoundedVec, + } + let wire = Wire::deserialize(deserializer)?; + Self::from_sorted(wire.body, wire.signatures.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!( + SignedEventIntentApproval, + "signed event intent approval bytes" +); + +/// Sorted controller approvals proving a threshold-approved proposal intent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EventIntentApprovals( + BoundedVec, +); + +impl EventIntentApprovals { + /// Sort and construct a duplicate-free controller intent set. + pub fn new(mut approvals: Vec) -> Result { + approvals.sort_unstable_by_key(|approval| approval.body().controller_id()); + Self::from_sorted(approvals) + } + + fn from_sorted(approvals: Vec) -> Result { + if approvals.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "event intent approvals", + }); + } + let approvals = BoundedVec::new("event intent approvals", approvals)?; + for pair in approvals.as_slice().windows(2) { + let left = pair[0].body().controller_id(); + let right = pair[1].body().controller_id(); + if left == right { + return Err(IdentityError::DuplicateElement { + resource: "event intent controllers", + }); + } + if left > right { + return Err(IdentityError::NonCanonical); + } + } + let proposal = approvals.as_slice()[0].body().proposal_id(); + if approvals + .as_slice() + .iter() + .any(|approval| approval.body().proposal_id() != proposal) + { + return Err(IdentityError::InvalidRelationship { + resource: "event intent proposal set", + }); + } + Ok(Self(approvals)) + } + + /// Canonically ordered controller intent approvals. + pub fn as_slice(&self) -> &[SignedEventIntentApproval] { + self.0.as_slice() + } + + /// Proposal shared by every approval. + pub fn proposal_id(&self) -> ProposalId { + self.0.as_slice()[0].body().proposal_id() + } +} + +impl<'de> Deserialize<'de> for EventIntentApprovals { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let values = + BoundedVec::::deserialize( + deserializer, + )?; + Self::from_sorted(values.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(EventIntentApprovals, "event intent approval set bytes"); + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FreshnessEvidenceKind { + LocalKnown(CheckpointId), + ProviderQuorum { + checkpoint_id: CheckpointId, + provider_policy_id: ProviderPolicyId, + receipts: ProviderReceipts, + }, +} + +/// Historical checkpoint freshness evidence used when admitting an event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshnessEvidence(FreshnessEvidenceKind); + +impl FreshnessEvidence { + /// Use the locally trusted checkpoint without claiming provider freshness. + pub const fn local_known(checkpoint_id: CheckpointId) -> Self { + Self(FreshnessEvidenceKind::LocalKnown(checkpoint_id)) + } + + /// Construct provider-quorum evidence for one checkpoint. + pub fn provider_quorum( + checkpoint_id: CheckpointId, + provider_policy_id: ProviderPolicyId, + receipts: ProviderReceipts, + ) -> Result { + if receipts.as_slice().is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "freshness provider receipts", + }); + } + if receipts.as_slice().iter().any(|receipt| { + receipt.entry().subject() != ProviderLogSubject::Checkpoint(checkpoint_id) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "freshness checkpoint receipts", + }); + } + Ok(Self(FreshnessEvidenceKind::ProviderQuorum { + checkpoint_id, + provider_policy_id, + receipts, + })) + } + + /// Checkpoint whose freshness is evidenced. + pub const fn checkpoint_id(&self) -> CheckpointId { + match &self.0 { + FreshnessEvidenceKind::LocalKnown(id) + | FreshnessEvidenceKind::ProviderQuorum { + checkpoint_id: id, .. + } => *id, + } + } + + /// Account provider-policy identifier committed by replicated evidence. + pub const fn provider_policy_id(&self) -> Option { + match &self.0 { + FreshnessEvidenceKind::LocalKnown(_) => None, + FreshnessEvidenceKind::ProviderQuorum { + provider_policy_id, .. + } => Some(*provider_policy_id), + } + } + + /// Signed provider receipts, absent for local-known evidence. + pub const fn provider_receipts(&self) -> Option<&ProviderReceipts> { + match &self.0 { + FreshnessEvidenceKind::LocalKnown(_) => None, + FreshnessEvidenceKind::ProviderQuorum { receipts, .. } => Some(receipts), + } + } +} + +impl Serialize for FreshnessEvidence { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.0 { + FreshnessEvidenceKind::LocalKnown(checkpoint_id) => { + (1u16, checkpoint_id).serialize(serializer) + } + FreshnessEvidenceKind::ProviderQuorum { + checkpoint_id, + provider_policy_id, + receipts, + } => (2u16, (checkpoint_id, provider_policy_id, receipts)).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for FreshnessEvidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + impl<'de> de::Visitor<'de> for Visitor { + type Value = FreshnessEvidence; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("v1 freshness evidence") + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => Ok(FreshnessEvidence::local_known( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )), + 2 => { + let (checkpoint_id, provider_policy_id, receipts) = sequence + .next_element::<(CheckpointId, ProviderPolicyId, ProviderReceipts)>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + FreshnessEvidence::provider_quorum( + checkpoint_id, + provider_policy_id, + receipts, + ) + .map_err(de::Error::custom) + } + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "freshness evidence", + code: unsupported, + })), + } + } + } + deserializer.deserialize_tuple(2, Visitor) + } +} + +canonical_schema!(FreshnessEvidence, "freshness evidence bytes"); + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DelayEvidenceKind { + None, + ProviderQuorum { + provider_policy_id: ProviderPolicyId, + required_quorum: crate::ProviderQuorum, + observed_at: Timestamp, + intent_approvals: EventIntentApprovals, + receipts: ProviderReceipts, + }, + GuardianRecovery { + provider_policy_id: ProviderPolicyId, + required_quorum: crate::ProviderQuorum, + observed_at: Timestamp, + proposal_id: ProposalId, + receipts: ProviderReceipts, + }, +} + +/// Provider-observed proposal intent proving an elapsed policy delay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DelayEvidence(DelayEvidenceKind); + +impl DelayEvidence { + /// No delay evidence, valid only for a no-delay policy rule. + pub const fn none() -> Self { + Self(DelayEvidenceKind::None) + } + + /// Construct provider-observed evidence for one threshold-approved intent. + pub fn provider_quorum( + provider_policy_id: ProviderPolicyId, + required_quorum: crate::ProviderQuorum, + intent_approvals: EventIntentApprovals, + receipts: ProviderReceipts, + ) -> Result { + if receipts.as_slice().is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "delay provider receipts", + }); + } + let proposal_id = intent_approvals.proposal_id(); + if receipts.as_slice().iter().any(|receipt| { + receipt.entry().subject() != ProviderLogSubject::EventIntent(proposal_id) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "delay proposal intent receipts", + }); + } + let quorum = usize::from(required_quorum.get()); + if receipts.as_slice().len() < quorum { + return Err(IdentityError::UnsatisfiableThreshold); + } + let mut observations = receipts + .as_slice() + .iter() + .map(|receipt| receipt.entry().observed_at()) + .collect::>(); + observations.sort_unstable(); + let observed_at = observations[quorum - 1]; + Ok(Self(DelayEvidenceKind::ProviderQuorum { + provider_policy_id, + required_quorum, + observed_at, + intent_approvals, + receipts, + })) + } + + /// Construct provider-observed evidence for guardian authority embedded in a recovery intent. + /// + /// No controller intent approvals are accepted by this shape. The exact guardian approval set + /// is already committed by the provider-receipted proposal body and is reverified against the + /// authenticated provider authority time when the event is applied. + pub fn guardian_recovery( + provider_policy_id: ProviderPolicyId, + required_quorum: crate::ProviderQuorum, + receipts: ProviderReceipts, + ) -> Result { + let first = receipts + .as_slice() + .first() + .ok_or(IdentityError::EmptyCollection { + resource: "guardian recovery delay provider receipts", + })?; + let ProviderLogSubject::EventIntent(proposal_id) = first.entry().subject() else { + return Err(IdentityError::InvalidRelationship { + resource: "guardian recovery delay receipt subject", + }); + }; + if receipts.as_slice().iter().any(|receipt| { + receipt.entry().subject() != ProviderLogSubject::EventIntent(proposal_id) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "guardian recovery delay proposal receipts", + }); + } + let quorum = usize::from(required_quorum.get()); + if receipts.as_slice().len() < quorum { + return Err(IdentityError::UnsatisfiableThreshold); + } + let mut observations = receipts + .as_slice() + .iter() + .map(|receipt| receipt.entry().observed_at()) + .collect::>(); + observations.sort_unstable(); + let observed_at = observations[quorum - 1]; + Ok(Self(DelayEvidenceKind::GuardianRecovery { + provider_policy_id, + required_quorum, + observed_at, + proposal_id, + receipts, + })) + } + + fn proposal_id(&self) -> Option { + match &self.0 { + DelayEvidenceKind::None => None, + DelayEvidenceKind::ProviderQuorum { + intent_approvals, .. + } => Some(intent_approvals.proposal_id()), + DelayEvidenceKind::GuardianRecovery { proposal_id, .. } => Some(*proposal_id), + } + } + + /// Account provider-policy identifier committed by delayed evidence. + pub const fn provider_policy_id(&self) -> Option { + match &self.0 { + DelayEvidenceKind::None => None, + DelayEvidenceKind::ProviderQuorum { + provider_policy_id, .. + } + | DelayEvidenceKind::GuardianRecovery { + provider_policy_id, .. + } => Some(*provider_policy_id), + } + } + + /// Deterministic quorum-th earliest distinct-provider observation. + pub const fn observed_at(&self) -> Option { + match &self.0 { + DelayEvidenceKind::None => None, + DelayEvidenceKind::ProviderQuorum { observed_at, .. } + | DelayEvidenceKind::GuardianRecovery { observed_at, .. } => Some(*observed_at), + } + } + + /// Quorum used to derive the signed observation anchor. + pub const fn required_quorum(&self) -> Option { + match &self.0 { + DelayEvidenceKind::None => None, + DelayEvidenceKind::ProviderQuorum { + required_quorum, .. + } + | DelayEvidenceKind::GuardianRecovery { + required_quorum, .. + } => Some(*required_quorum), + } + } + + /// Threshold-approved intent carried by provider-quorum delay evidence. + pub const fn intent_approvals(&self) -> Option<&EventIntentApprovals> { + match &self.0 { + DelayEvidenceKind::None => None, + DelayEvidenceKind::ProviderQuorum { + intent_approvals, .. + } => Some(intent_approvals), + DelayEvidenceKind::GuardianRecovery { .. } => None, + } + } + + /// Whether the delayed intent carries embedded guardian recovery authority rather than + /// unrelated controller-intent approvals. + pub const fn is_guardian_recovery(&self) -> bool { + matches!(self.0, DelayEvidenceKind::GuardianRecovery { .. }) + } + + /// Signed distinct-provider receipts carried by delayed evidence. + pub const fn provider_receipts(&self) -> Option<&ProviderReceipts> { + match &self.0 { + DelayEvidenceKind::None => None, + DelayEvidenceKind::ProviderQuorum { receipts, .. } + | DelayEvidenceKind::GuardianRecovery { receipts, .. } => Some(receipts), + } + } +} + +impl Serialize for DelayEvidence { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.0 { + DelayEvidenceKind::None => (0u16, ()).serialize(serializer), + DelayEvidenceKind::ProviderQuorum { + provider_policy_id, + required_quorum, + observed_at, + intent_approvals, + receipts, + } => ( + 1u16, + ( + provider_policy_id, + required_quorum, + observed_at, + intent_approvals, + receipts, + ), + ) + .serialize(serializer), + DelayEvidenceKind::GuardianRecovery { + provider_policy_id, + required_quorum, + observed_at, + receipts, + .. + } => ( + 2u16, + (provider_policy_id, required_quorum, observed_at, receipts), + ) + .serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for DelayEvidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + impl<'de> de::Visitor<'de> for Visitor { + type Value = DelayEvidence; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("v1 delay evidence") + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 0 => { + sequence + .next_element::<()>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + Ok(DelayEvidence::none()) + } + 1 => { + let ( + provider_policy_id, + required_quorum, + observed_at, + intent_approvals, + receipts, + ) = sequence + .next_element::<( + ProviderPolicyId, + crate::ProviderQuorum, + Timestamp, + EventIntentApprovals, + ProviderReceipts, + )>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + let evidence = DelayEvidence::provider_quorum( + provider_policy_id, + required_quorum, + intent_approvals, + receipts, + ) + .map_err(de::Error::custom)?; + if evidence.observed_at() != Some(observed_at) { + return Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "delay evidence observation anchor", + })); + } + Ok(evidence) + } + 2 => { + let (provider_policy_id, required_quorum, observed_at, receipts) = sequence + .next_element::<( + ProviderPolicyId, + crate::ProviderQuorum, + Timestamp, + ProviderReceipts, + )>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + let evidence = DelayEvidence::guardian_recovery( + provider_policy_id, + required_quorum, + receipts, + ) + .map_err(de::Error::custom)?; + if evidence.observed_at() != Some(observed_at) { + return Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "guardian recovery delay evidence observation anchor", + })); + } + Ok(evidence) + } + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "delay evidence", + code: unsupported, + })), + } + } + } + deserializer.deserialize_tuple(2, Visitor) + } +} + +canonical_schema!(DelayEvidence, "delay evidence bytes"); + +/// Signed historical evidence used to admit one exact account event body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AdmissionEvidence { + protocol_version: ProtocolVersion, + proposal_id: ProposalId, + preceding_checkpoint: CheckpointId, + provider_policy_id: ProviderPolicyId, + freshness: FreshnessEvidence, + delay: DelayEvidence, + extensions: Extensions, +} + +impl AdmissionEvidence { + /// Construct internally consistent event admission evidence. + pub fn new( + proposal_id: ProposalId, + preceding_checkpoint: CheckpointId, + provider_policy_id: ProviderPolicyId, + freshness: FreshnessEvidence, + delay: DelayEvidence, + extensions: Extensions, + ) -> Result { + if freshness.checkpoint_id() != preceding_checkpoint { + return Err(IdentityError::InvalidRelationship { + resource: "admission preceding/freshness checkpoint", + }); + } + if delay + .proposal_id() + .is_some_and(|delayed_proposal| delayed_proposal != proposal_id) + { + return Err(IdentityError::InvalidRelationship { + resource: "admission delayed proposal", + }); + } + if freshness + .provider_policy_id() + .is_some_and(|evidence_policy| evidence_policy != provider_policy_id) + || delay + .provider_policy_id() + .is_some_and(|evidence_policy| evidence_policy != provider_policy_id) + { + return Err(IdentityError::InvalidRelationship { + resource: "admission provider policy", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + proposal_id, + preceding_checkpoint, + provider_policy_id, + freshness, + delay, + extensions, + }) + } + + /// Derive the exact historical admission evidence identifier. + pub fn admission_evidence_id(&self) -> Result { + AdmissionEvidenceId::derive(self) + } + + /// Derive the final history identifier for the exact body this evidence admits. + pub fn event_id_for_body(&self, body: &EventBody) -> Result { + if self.proposal_id != body.proposal_id()? { + return Err(IdentityError::InvalidRelationship { + resource: "authorized event admission subject", + }); + } + body.admitted_event_id(self.admission_evidence_id()?) + } + + /// Proposal admitted by this evidence. + pub const fn proposal_id(&self) -> ProposalId { + self.proposal_id + } + + /// Prior checkpoint used as the historical admission basis. + pub const fn preceding_checkpoint(&self) -> CheckpointId { + self.preceding_checkpoint + } + + /// Exact pre-state account provider-policy identifier. + pub const fn provider_policy_id(&self) -> ProviderPolicyId { + self.provider_policy_id + } + + /// Historical freshness basis. + pub const fn freshness(&self) -> &FreshnessEvidence { + &self.freshness + } + + /// Historical policy-delay basis. + pub const fn delay(&self) -> &DelayEvidence { + &self.delay + } +} + +impl<'de> Deserialize<'de> for AdmissionEvidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + proposal_id: ProposalId, + preceding_checkpoint: CheckpointId, + provider_policy_id: ProviderPolicyId, + freshness: FreshnessEvidence, + delay: DelayEvidence, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + Self::new( + wire.proposal_id, + wire.preceding_checkpoint, + wire.provider_policy_id, + wire.freshness, + wire.delay, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(AdmissionEvidence, "admission evidence bytes"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ControllerApprovalSubject { + Event { + event_id: EventId, + admission_evidence_id: AdmissionEvidenceId, + }, + Checkpoint(CheckpointId), +} + +impl Serialize for ControllerApprovalSubject { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Event { + event_id, + admission_evidence_id, + } => (1u16, (event_id, admission_evidence_id)).serialize(serializer), + Self::Checkpoint(checkpoint_id) => (2u16, checkpoint_id).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for ControllerApprovalSubject { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + impl<'de> de::Visitor<'de> for Visitor { + type Value = ControllerApprovalSubject; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("v1 controller approval subject") + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => { + let (event_id, admission_evidence_id) = sequence + .next_element::<(EventId, AdmissionEvidenceId)>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + Ok(ControllerApprovalSubject::Event { + event_id, + admission_evidence_id, + }) + } + 2 => Ok(ControllerApprovalSubject::Checkpoint( + sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?, + )), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "controller approval subject", + code: unsupported, + })), + } + } + } + deserializer.deserialize_tuple(2, Visitor) + } +} + +/// Exact controller approval body; signatures form an outer mergeable set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ControllerApprovalBody { + protocol_version: ProtocolVersion, + controller_id: ControllerId, + subject: ControllerApprovalSubject, + extensions: Extensions, +} + +impl ControllerApprovalBody { + /// Construct a final account-event approval body. + pub fn event( + controller_id: ControllerId, + event_id: EventId, + admission_evidence_id: AdmissionEvidenceId, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + controller_id, + subject: ControllerApprovalSubject::Event { + event_id, + admission_evidence_id, + }, + extensions, + }) + } + + /// Construct a checkpoint approval body. + pub fn checkpoint( + controller_id: ControllerId, + checkpoint_id: CheckpointId, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + controller_id, + subject: ControllerApprovalSubject::Checkpoint(checkpoint_id), + extensions, + }) + } + + /// Approving controller. + pub const fn controller_id(&self) -> ControllerId { + self.controller_id + } + + /// Approved checkpoint, when this body is a checkpoint approval. + pub const fn checkpoint_id(&self) -> Option { + match self.subject { + ControllerApprovalSubject::Checkpoint(id) => Some(id), + ControllerApprovalSubject::Event { .. } => None, + } + } + + /// Approved event and evidence IDs, when this is an event approval. + pub const fn event_subject(&self) -> Option<(EventId, AdmissionEvidenceId)> { + match self.subject { + ControllerApprovalSubject::Event { + event_id, + admission_evidence_id, + } => Some((event_id, admission_evidence_id)), + ControllerApprovalSubject::Checkpoint(_) => None, + } + } + + /// Derive the exact signed approval-body identifier. + pub fn controller_approval_id(&self) -> Result { + ControllerApprovalId::derive(self) + } +} + +impl<'de> Deserialize<'de> for ControllerApprovalBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + controller_id: ControllerId, + subject: ControllerApprovalSubject, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + let _ = wire.protocol_version; + match wire.subject { + ControllerApprovalSubject::Event { + event_id, + admission_evidence_id, + } => Self::event( + wire.controller_id, + event_id, + admission_evidence_id, + wire.extensions, + ), + ControllerApprovalSubject::Checkpoint(checkpoint_id) => { + Self::checkpoint(wire.controller_id, checkpoint_id, wire.extensions) + } + } + .map_err(de::Error::custom) + } +} + +canonical_schema!(ControllerApprovalBody, "controller approval body bytes"); + +/// Mergeable suite signatures from one controller over one approval body. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedControllerApproval { + body: ControllerApprovalBody, + signatures: BoundedVec, +} + +impl SignedControllerApproval { + /// Sort and construct duplicate-free suite signatures. + pub fn new( + body: ControllerApprovalBody, + mut signatures: Vec, + ) -> Result { + signatures.sort_unstable_by_key(KeyedSignature::sort_key); + Self::from_sorted(body, signatures) + } + + fn from_sorted( + body: ControllerApprovalBody, + signatures: Vec, + ) -> Result { + if signatures.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "controller approval signatures", + }); + } + let signatures = BoundedVec::new("controller approval signatures", signatures)?; + for pair in signatures.as_slice().windows(2) { + if pair[0].sort_key() == pair[1].sort_key() { + return Err(IdentityError::DuplicateElement { + resource: "controller approval signatures", + }); + } + if pair[0].sort_key() > pair[1].sort_key() { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self { body, signatures }) + } + + /// Signed approval body. + pub const fn body(&self) -> &ControllerApprovalBody { + &self.body + } + + /// Sorted suite signatures over the canonical approval body. + pub fn signatures(&self) -> &[KeyedSignature] { + self.signatures.as_slice() + } + + /// Merge canonical suite signatures for the same exact controller approval body. + /// + /// Repeating an identical signature is idempotent. Two different signatures claiming the + /// same suite/key slot are rejected instead of selecting one by arrival order. + pub fn merge(&self, other: &Self) -> Result { + if self.body != other.body { + return Err(IdentityError::InvalidRelationship { + resource: "merged controller approval body", + }); + } + let mut signatures = self.signatures.as_slice().to_vec(); + signatures.extend_from_slice(other.signatures.as_slice()); + signatures.sort_unstable_by_key(KeyedSignature::sort_key); + let mut merged: Vec = Vec::with_capacity(signatures.len()); + for signature in signatures { + if let Some(previous) = merged.last() + && previous.sort_key() == signature.sort_key() + { + if previous != &signature { + return Err(IdentityError::InvalidSignature); + } + continue; + } + merged.push(signature); + } + Self::from_sorted(self.body.clone(), merged) + } +} + +impl<'de> Deserialize<'de> for SignedControllerApproval { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + body: ControllerApprovalBody, + signatures: BoundedVec, + } + let wire = Wire::deserialize(deserializer)?; + Self::from_sorted(wire.body, wire.signatures.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(SignedControllerApproval, "signed controller approval bytes"); + +/// Sorted, duplicate-free final controller approvals. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ControllerApprovals(BoundedVec); + +impl ControllerApprovals { + /// Sort approvals by controller identifier. + pub fn new(mut approvals: Vec) -> Result { + approvals.sort_unstable_by_key(|approval| approval.body().controller_id()); + Self::from_sorted(approvals) + } + + fn from_sorted(approvals: Vec) -> Result { + let approvals = BoundedVec::new("controller approvals", approvals)?; + for pair in approvals.as_slice().windows(2) { + let left = pair[0].body().controller_id(); + let right = pair[1].body().controller_id(); + if left == right { + return Err(IdentityError::DuplicateElement { + resource: "controller approvals", + }); + } + if left > right { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self(approvals)) + } + + /// Canonically ordered controller approvals. + /// + /// An empty set is only valid when the containing [`AuthorizedEvent`] operation carries its + /// complete authority elsewhere. [`AuthorizedEvent::new`] enforces that contextual rule. + pub fn as_slice(&self) -> &[SignedControllerApproval] { + self.0.as_slice() + } + + /// Merge controller evidence as a canonical signer/signature union. + /// + /// The operation is commutative and idempotent for identical valid evidence. Approvals from + /// the same controller must bind the same exact approval body. + pub fn merge(&self, other: &Self) -> Result { + let mut merged = self.0.as_slice().to_vec(); + for incoming in other.0.as_slice() { + let controller_id = incoming.body().controller_id(); + match merged + .binary_search_by_key(&controller_id, |approval| approval.body().controller_id()) + { + Ok(index) => merged[index] = merged[index].merge(incoming)?, + Err(index) => merged.insert(index, incoming.clone()), + } + } + Self::from_sorted(merged) + } +} + +impl<'de> Deserialize<'de> for ControllerApprovals { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let values = + BoundedVec::::deserialize( + deserializer, + )?; + Self::from_sorted(values.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(ControllerApprovals, "controller approval set bytes"); + +/// Complete admitted account event with mergeable final controller approvals. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuthorizedEvent { + body: EventBody, + admission_evidence: AdmissionEvidence, + approvals: ControllerApprovals, +} + +impl AuthorizedEvent { + /// Construct an event whose evidence and every approval bind the same body IDs. + pub fn new( + body: EventBody, + admission_evidence: AdmissionEvidence, + approvals: ControllerApprovals, + ) -> Result { + let event_id = admission_evidence.event_id_for_body(&body)?; + if let AccountOperation::BeginRecovery(begin) = body.operation() + && admission_evidence.preceding_checkpoint() + != begin.proposal().plan().prior_checkpoint_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "begin recovery admission checkpoint", + }); + } + let requires_empty_controller_approvals = match body.operation() { + AccountOperation::BeginRecovery(begin) => { + begin.threshold_evidence().as_guardian_approvals().is_some() + } + AccountOperation::CancelRecovery(cancel) => cancel + .threshold_evidence() + .as_guardian_approvals() + .is_some(), + AccountOperation::FinalizeRecovery(_) => true, + _ => false, + }; + if approvals.as_slice().is_empty() != requires_empty_controller_approvals { + return Err(IdentityError::InvalidRelationship { + resource: "authorized event controller approval cardinality", + }); + } + let admission_evidence_id = admission_evidence.admission_evidence_id()?; + if approvals.as_slice().iter().any(|approval| { + approval.body().event_subject() != Some((event_id, admission_evidence_id)) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "authorized event approval subject", + }); + } + let event = Self { + body, + admission_evidence, + approvals, + }; + let encoded_len = encode_wire(&event)?.len(); + if encoded_len > MAX_ACCOUNT_EVENT_BYTES { + return Err(IdentityError::limit( + "authorized account event bytes", + encoded_len, + MAX_ACCOUNT_EVENT_BYTES, + )); + } + Ok(event) + } + + /// Canonical body whose intent ID is committed by the stable event identifier. + pub const fn body(&self) -> &EventBody { + &self.body + } + + /// Historical freshness and delay basis bound by final approvals. + pub const fn admission_evidence(&self) -> &AdmissionEvidence { + &self.admission_evidence + } + + /// Sorted final controller approvals, mergeable without changing the event ID. + pub const fn approvals(&self) -> &ControllerApprovals { + &self.approvals + } + + /// Stable event identifier committing the body and exact admission evidence. + pub fn event_id(&self) -> Result { + self.admission_evidence.event_id_for_body(&self.body) + } + + /// Domain-separated identifier of this exact evidence-and-approval envelope. + /// + /// Unlike [`EventId`], this identifier changes when valid late approvals are merged. It is + /// used only when a checkpoint explicitly refers to the retained complete proof. + pub fn event_authorization_id(&self) -> Result { + EventAuthorizationId::derive(self) + } +} + +impl<'de> Deserialize<'de> for AuthorizedEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + body: EventBody, + admission_evidence: AdmissionEvidence, + approvals: ControllerApprovals, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.body, wire.admission_evidence, wire.approvals).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for AuthorizedEvent { + const RESOURCE: &'static str = "authorized account event bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} diff --git a/protocols/krikos-identity/src/extension.rs b/protocols/krikos-identity/src/extension.rs new file mode 100644 index 00000000000..49654dd2d85 --- /dev/null +++ b/protocols/krikos-identity/src/extension.rs @@ -0,0 +1,303 @@ +//! Bounded forward-compatible extension fields. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, de, ser::SerializeSeq}; + +use crate::{ + IdentityError, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_EXTENSION_VALUE_BYTES, MAX_EXTENSIONS, MAX_TOTAL_EXTENSION_BYTES}, +}; + +/// One opaque, signed extension field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Extension { + code: u32, + critical: bool, + value: Vec, +} + +impl Extension { + /// Construct a bounded extension. + pub fn new(code: u32, critical: bool, value: Vec) -> Result { + if code == 0 { + return Err(IdentityError::InvalidExtensionCode { code }); + } + if value.len() > MAX_EXTENSION_VALUE_BYTES { + return Err(IdentityError::limit( + "extension value bytes", + value.len(), + MAX_EXTENSION_VALUE_BYTES, + )); + } + Ok(Self { + code, + critical, + value, + }) + } + + /// Stable extension registry code. + pub const fn code(&self) -> u32 { + self.code + } + + /// Whether an implementation must understand this field to accept its object. + pub const fn is_critical(&self) -> bool { + self.critical + } + + /// Opaque canonical extension value bytes. + pub fn value(&self) -> &[u8] { + &self.value + } + + fn into_wire(self) -> ExtensionWire { + ExtensionWire { + code: self.code, + critical: self.critical, + value: BoundedBytes(self.value), + } + } +} + +/// Sorted, duplicate-free extension fields on one extensible object. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct Extensions(Vec); + +impl Extensions { + /// Validate, sort, and construct an extension set. + pub fn new(mut extensions: Vec) -> Result { + if extensions.len() > MAX_EXTENSIONS { + return Err(IdentityError::limit( + "extension fields", + extensions.len(), + MAX_EXTENSIONS, + )); + } + extensions.sort_unstable_by_key(Extension::code); + Self::from_sorted(extensions) + } + + /// Borrow the canonical sorted fields. + pub fn as_slice(&self) -> &[Extension] { + &self.0 + } + + /// Reject critical fields not present in a sorted bounded known-code registry. + pub fn validate_critical(&self, known_codes: &[u32]) -> Result<(), IdentityError> { + if known_codes.len() > MAX_EXTENSIONS { + return Err(IdentityError::limit( + "known extension codes", + known_codes.len(), + MAX_EXTENSIONS, + )); + } + if known_codes.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(IdentityError::NonCanonical); + } + for extension in &self.0 { + if extension.critical && known_codes.binary_search(&extension.code).is_err() { + return Err(IdentityError::UnknownCriticalExtension { + code: extension.code, + }); + } + } + Ok(()) + } + + fn from_sorted(extensions: Vec) -> Result { + if extensions.len() > MAX_EXTENSIONS { + return Err(IdentityError::limit( + "extension fields", + extensions.len(), + MAX_EXTENSIONS, + )); + } + for pair in extensions.windows(2) { + if pair[0].code == pair[1].code { + return Err(IdentityError::DuplicateExtension { code: pair[0].code }); + } + if pair[0].code > pair[1].code { + return Err(IdentityError::NonCanonical); + } + } + let mut total = 0usize; + for extension in &extensions { + total = total.checked_add(extension.value.len()).ok_or( + IdentityError::ArithmeticOverflow { + resource: "total extension bytes", + }, + )?; + if total > MAX_TOTAL_EXTENSION_BYTES { + return Err(IdentityError::limit( + "total extension bytes", + total, + MAX_TOTAL_EXTENSION_BYTES, + )); + } + } + Ok(Self(extensions)) + } +} + +impl CanonicalCodec for Extensions { + const RESOURCE: &'static str = "extension bytes"; + const MAX_ENCODED_BYTES: usize = MAX_TOTAL_EXTENSION_BYTES + MAX_EXTENSIONS * 16; + + fn encode_canonical(&self) -> Result, IdentityError> { + let wire = ExtensionList( + self.0 + .clone() + .into_iter() + .map(Extension::into_wire) + .collect(), + ); + encode_wire(&wire) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let wire: ExtensionList = decode_wire(bytes)?; + let extensions = wire + .0 + .into_iter() + .map(|wire| Extension::new(wire.code, wire.critical, wire.value.0)) + .collect::, _>>()?; + Self::from_sorted(extensions) + } +} + +impl<'de> Deserialize<'de> for Extensions { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = ExtensionList::deserialize(deserializer)?; + let extensions = wire + .0 + .into_iter() + .map(|wire| Extension::new(wire.code, wire.critical, wire.value.0)) + .collect::, _>>() + .map_err(de::Error::custom)?; + Self::from_sorted(extensions).map_err(de::Error::custom) + } +} + +#[derive(Serialize, Deserialize)] +struct ExtensionWire { + code: u32, + critical: bool, + value: BoundedBytes, +} + +struct ExtensionList(Vec); + +impl Serialize for ExtensionList { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut sequence = serializer.serialize_seq(Some(self.0.len()))?; + for extension in &self.0 { + sequence.serialize_element(extension)?; + } + sequence.end() + } +} + +impl<'de> Deserialize<'de> for ExtensionList { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = ExtensionList; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "at most {MAX_EXTENSIONS} extension fields") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + if sequence + .size_hint() + .is_some_and(|hint| hint > MAX_EXTENSIONS) + { + return Err(de::Error::invalid_length(MAX_EXTENSIONS + 1, &self)); + } + let mut extensions = + Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX_EXTENSIONS)); + while let Some(extension) = sequence.next_element()? { + if extensions.len() == MAX_EXTENSIONS { + return Err(de::Error::invalid_length(MAX_EXTENSIONS + 1, &self)); + } + extensions.push(extension); + } + Ok(ExtensionList(extensions)) + } + } + + deserializer.deserialize_seq(Visitor) + } +} + +#[derive(Serialize)] +struct BoundedBytes(Vec); + +impl<'de> Deserialize<'de> for BoundedBytes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = BoundedBytes; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "at most {MAX_EXTENSION_VALUE_BYTES} extension bytes" + ) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + if sequence + .size_hint() + .is_some_and(|hint| hint > MAX_EXTENSION_VALUE_BYTES) + { + return Err(de::Error::invalid_length( + MAX_EXTENSION_VALUE_BYTES + 1, + &self, + )); + } + let mut bytes = Vec::with_capacity( + sequence + .size_hint() + .unwrap_or(0) + .min(MAX_EXTENSION_VALUE_BYTES), + ); + while let Some(byte) = sequence.next_element()? { + if bytes.len() == MAX_EXTENSION_VALUE_BYTES { + return Err(de::Error::invalid_length( + MAX_EXTENSION_VALUE_BYTES + 1, + &self, + )); + } + bytes.push(byte); + } + Ok(BoundedBytes(bytes)) + } + } + + deserializer.deserialize_seq(Visitor) + } +} diff --git a/protocols/krikos-identity/src/freshness.rs b/protocols/krikos-identity/src/freshness.rs new file mode 100644 index 00000000000..8553435c602 --- /dev/null +++ b/protocols/krikos-identity/src/freshness.rs @@ -0,0 +1,183 @@ +//! Explicit checkpoint freshness decisions with monotonic caller tightening. + +use crate::{ + AuthorizationContext, DurationMillis, FreshnessEvidence, FreshnessRequirement, IdentityError, + ProviderMode, ProviderPolicy, ProviderPolicyId, ProviderQuorum, Timestamp, +}; + +/// Verified freshness basis for one exact account/checkpoint/epoch context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FreshnessDecision { + context: AuthorizationContext, + provider_policy_id: ProviderPolicyId, + required_quorum: Option, + maximum_age: Option, + provider_observed_at: Option, +} + +impl FreshnessDecision { + /// Exact account, epoch, and checkpoint to which the decision applies. + pub const fn context(self) -> AuthorizationContext { + self.context + } + + /// Authenticated provider policy used by the decision. + pub const fn provider_policy_id(self) -> ProviderPolicyId { + self.provider_policy_id + } + + /// Effective distinct-provider quorum, absent for latest-known-only evaluation. + pub const fn required_quorum(self) -> Option { + self.required_quorum + } + + /// Effective age bound at the explicit verifier time, absent for latest-known-only evaluation. + pub const fn maximum_age(self) -> Option { + self.maximum_age + } + + /// Deterministic quorum-th checkpoint observation time, if online evidence was required. + pub const fn provider_observed_at(self) -> Option { + self.provider_observed_at + } +} + +/// Evaluate account and caller freshness requirements without permitting caller weakening. +/// +/// Provider maximum age is measured from the signed checkpoint-log observation to the explicit +/// verifier time. A later tree head proves continued inclusion but cannot refresh the checkpoint's +/// original observation. The checkpoint's account-supplied metadata timestamp is never an +/// authority source. `LatestKnown` establishes only the exact locally trusted checkpoint context. +pub fn evaluate_freshness( + context: AuthorizationContext, + provider_policy: &ProviderPolicy, + account_requirement: FreshnessRequirement, + caller_requirement: FreshnessRequirement, + evidence: &FreshnessEvidence, + verified_at: Timestamp, +) -> Result { + if evidence.checkpoint_id() != context.checkpoint_id() { + return Err(IdentityError::InvalidRelationship { + resource: "freshness decision checkpoint", + }); + } + let provider_policy_id = provider_policy.id()?; + let requested = combine_requirements(account_requirement, caller_requirement); + let Some((requested_quorum, requested_maximum_age)) = requested else { + return Ok(FreshnessDecision { + context, + provider_policy_id, + required_quorum: None, + maximum_age: None, + provider_observed_at: None, + }); + }; + + let replicated = match provider_policy.mode() { + ProviderMode::LocalOnly => return Err(IdentityError::FreshnessUnavailable), + ProviderMode::Replicated(replicated) => replicated, + }; + if evidence.provider_policy_id() != Some(provider_policy_id) { + return Err(IdentityError::PolicyVersionMismatch); + } + let receipts = evidence + .provider_receipts() + .ok_or(IdentityError::FreshnessUnavailable)?; + let required = usize::from( + requested_quorum + .get() + .max(replicated.sufficient_threshold().get()), + ); + let maximum_age = DurationMillis::new( + requested_maximum_age + .get() + .min(replicated.maximum_evidence_age().get()), + ); + let required_quorum = ProviderQuorum::new(u16::try_from(required).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "freshness decision provider quorum", + } + })?)?; + let future_skew = + u64::try_from(crate::limits::MAX_FUTURE_CLOCK_SKEW.as_millis()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "freshness future clock skew milliseconds", + } + })?; + let maximum_observation = verified_at.checked_add(DurationMillis::new(future_skew))?; + let mut valid_times = Vec::new(); + let mut stale_configured = false; + for receipt in receipts.as_slice() { + if receipt.entry().account_id() != context.account_id() { + return Err(IdentityError::AccountMismatch); + } + let Some(provider) = replicated + .providers() + .iter() + .find(|provider| provider.id() == Ok(receipt.provider_id())) + else { + continue; + }; + receipt.verify(provider)?; + let entry_time = receipt.entry().observed_at().as_unix_millis(); + let head_time = receipt.signed_head().body().observed_at().as_unix_millis(); + if entry_time > maximum_observation.as_unix_millis() + || head_time > maximum_observation.as_unix_millis() + { + stale_configured = true; + continue; + } + let age = if verified_at.as_unix_millis() >= entry_time { + verified_at.as_unix_millis() - entry_time + } else { + 0 + }; + if age > maximum_age.get() { + stale_configured = true; + continue; + } + valid_times.push(receipt.entry().observed_at()); + } + if valid_times.len() < required { + return Err(if stale_configured { + IdentityError::StaleEvidence + } else { + IdentityError::FreshnessUnavailable + }); + } + valid_times.sort_unstable(); + Ok(FreshnessDecision { + context, + provider_policy_id, + required_quorum: Some(required_quorum), + maximum_age: Some(maximum_age), + provider_observed_at: Some(valid_times[required - 1]), + }) +} + +fn combine_requirements( + account: FreshnessRequirement, + caller: FreshnessRequirement, +) -> Option<(ProviderQuorum, DurationMillis)> { + match (account, caller) { + (FreshnessRequirement::LatestKnown, FreshnessRequirement::LatestKnown) => None, + (FreshnessRequirement::ProviderQuorum(requirement), FreshnessRequirement::LatestKnown) + | (FreshnessRequirement::LatestKnown, FreshnessRequirement::ProviderQuorum(requirement)) => { + Some((requirement.required(), requirement.maximum_age())) + } + ( + FreshnessRequirement::ProviderQuorum(account), + FreshnessRequirement::ProviderQuorum(caller), + ) => { + let required = if account.required() >= caller.required() { + account.required() + } else { + caller.required() + }; + Some(( + required, + DurationMillis::new(account.maximum_age().get().min(caller.maximum_age().get())), + )) + } + } +} diff --git a/protocols/krikos-identity/src/genesis.rs b/protocols/krikos-identity/src/genesis.rs new file mode 100644 index 00000000000..fc61659611d --- /dev/null +++ b/protocols/krikos-identity/src/genesis.rs @@ -0,0 +1,217 @@ +//! Canonical account genesis and stable account identity. + +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + AccountId, ControlPolicy, ControllerDescriptor, Extensions, GenesisAnchor, HashAlgorithm, + IdentityError, ProtocolVersion, ProviderPolicy, ProviderPolicyVersion, RecoveryPolicy, + RecoveryPolicyVersion, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::MAX_CONTROLLERS, + schema::BoundedVec, +}; + +/// Secret-free canonical root of one stable account identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AccountGenesis { + protocol_version: ProtocolVersion, + account_nonce: [u8; 32], + created_at: Timestamp, + hash_algorithm: HashAlgorithm, + initial_policy: ControlPolicy, + initial_controllers: BoundedVec, + initial_recovery_policy: RecoveryPolicy, + initial_provider_policy: ProviderPolicy, + extensions: Extensions, +} + +impl AccountGenesis { + /// Validate, canonically sort, and construct account genesis. + #[allow(clippy::too_many_arguments)] + pub fn new( + account_nonce: [u8; 32], + created_at: Timestamp, + initial_policy: ControlPolicy, + initial_controllers: Vec, + initial_recovery_policy: RecoveryPolicy, + initial_provider_policy: ProviderPolicy, + extensions: Extensions, + ) -> Result { + if initial_controllers.len() > MAX_CONTROLLERS { + return Err(IdentityError::limit( + "initial controllers", + initial_controllers.len(), + MAX_CONTROLLERS, + )); + } + let mut identified = initial_controllers + .into_iter() + .map(|controller| Ok((controller.id()?, controller))) + .collect::, IdentityError>>()?; + identified.sort_unstable_by_key(|(id, _)| *id); + let controllers = identified + .into_iter() + .map(|(_, controller)| controller) + .collect(); + Self::from_sorted( + account_nonce, + created_at, + initial_policy, + controllers, + initial_recovery_policy, + initial_provider_policy, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_sorted( + account_nonce: [u8; 32], + created_at: Timestamp, + initial_policy: ControlPolicy, + initial_controllers: Vec, + initial_recovery_policy: RecoveryPolicy, + initial_provider_policy: ProviderPolicy, + extensions: Extensions, + ) -> Result { + if account_nonce == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "account nonce", + }); + } + if initial_controllers.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "initial controllers", + }); + } + let initial_controllers = BoundedVec::new("initial controllers", initial_controllers)?; + for pair in initial_controllers.as_slice().windows(2) { + let left = pair[0].id()?; + let right = pair[1].id()?; + if left == right { + return Err(IdentityError::DuplicateElement { + resource: "initial controller identifiers", + }); + } + if left > right { + return Err(IdentityError::NonCanonical); + } + } + for (index, controller) in initial_controllers.as_slice().iter().enumerate() { + if initial_controllers.as_slice()[..index] + .iter() + .any(|prior| prior.signing_key() == controller.signing_key()) + { + return Err(IdentityError::DuplicateSigningKey); + } + } + if initial_provider_policy.policy_version() != ProviderPolicyVersion::GENESIS { + return Err(IdentityError::InvalidPolicy { + resource: "genesis provider version", + }); + } + if initial_recovery_policy.policy_version() != RecoveryPolicyVersion::GENESIS { + return Err(IdentityError::InvalidPolicy { + resource: "genesis recovery version", + }); + } + initial_policy.validate_satisfiable(initial_controllers.as_slice())?; + initial_recovery_policy.validate_controller_authority(initial_controllers.as_slice())?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + account_nonce, + created_at, + hash_algorithm: HashAlgorithm::Blake3_256, + initial_policy, + initial_controllers, + initial_recovery_policy, + initial_provider_policy, + extensions, + }) + } + + /// Stable account identifier derived directly from canonical genesis. + pub fn account_id(&self) -> Result { + AccountId::derive(self) + } + + /// Domain-separated predecessor required by the first account event. + pub fn genesis_anchor(&self) -> Result { + GenesisAnchor::derive(self) + } + + /// Explicit creation timestamp; metadata only, never ordering authority. + pub const fn created_at(&self) -> Timestamp { + self.created_at + } + + /// Initial control policy. + pub const fn initial_policy(&self) -> &ControlPolicy { + &self.initial_policy + } + + /// Canonically ordered initial controllers. + pub fn initial_controllers(&self) -> &[ControllerDescriptor] { + self.initial_controllers.as_slice() + } + + /// Initial recovery policy. + pub const fn initial_recovery_policy(&self) -> &RecoveryPolicy { + &self.initial_recovery_policy + } + + /// Initial provider policy. + pub const fn initial_provider_policy(&self) -> &ProviderPolicy { + &self.initial_provider_policy + } +} + +impl<'de> Deserialize<'de> for AccountGenesis { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_nonce: [u8; 32], + created_at: Timestamp, + hash_algorithm: HashAlgorithm, + initial_policy: ControlPolicy, + initial_controllers: BoundedVec, + initial_recovery_policy: RecoveryPolicy, + initial_provider_policy: ProviderPolicy, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 + || wire.hash_algorithm != HashAlgorithm::Blake3_256 + { + return Err(de::Error::custom(IdentityError::InvalidEncoding)); + } + Self::from_sorted( + wire.account_nonce, + wire.created_at, + wire.initial_policy, + wire.initial_controllers.into_vec(), + wire.initial_recovery_policy, + wire.initial_provider_policy, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for AccountGenesis { + const RESOURCE: &'static str = "account genesis bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} diff --git a/protocols/krikos-identity/src/key_wrap.rs b/protocols/krikos-identity/src/key_wrap.rs new file mode 100644 index 00000000000..ab42da07e1b --- /dev/null +++ b/protocols/krikos-identity/src/key_wrap.rs @@ -0,0 +1,1183 @@ +//! Canonical application group-key wrap headers and recipient sets. + +use std::fmt; + +use chacha20poly1305::{ + Key, XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, Payload}, +}; +use rand_core::CryptoRng; +use serde::{Deserialize, Deserializer, Serialize, de}; +use x25519_dalek::{PublicKey, SharedSecret, StaticSecret}; +use zeroize::Zeroizing; + +use crate::{ + AccountId, AccountRevision, AccountState, AgreementPublicKey, ApplicationId, CryptoSuiteId, + DeviceAuthorization, DeviceId, Digest, Epoch, Extensions, GroupId, GroupKeyEpoch, + GroupKeyWrapId, IdentityError, ProjectedDeviceLifecycle, ProjectionLifecycle, ProtocolVersion, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_DEVICES, MAX_ENCODED_OBJECT_BYTES, MAX_KEY_WRAP_BYTES}, + schema::{BoundedBytes, BoundedVec}, + types::{HashDomain, hash_bytes}, +}; + +const GROUP_KEY_BYTES: usize = 32; +const KEY_WRAP_TAG_BYTES: usize = 16; +const KEY_WRAP_CIPHERTEXT_BYTES: usize = GROUP_KEY_BYTES + KEY_WRAP_TAG_BYTES; +const KEY_WRAP_KDF_CONTEXT: &str = "KRIKOS-ID/group-key-wrap-key/v1"; +const KEY_WRAP_KDF_MATERIAL_BYTES: usize = 32 + 32 + 32; + +fn v1_crypto_suite_id() -> Result { + crate::CryptoSuiteDescriptor::v1()?.crypto_suite_id() +} + +fn validate_v1_crypto_suite(crypto_suite_id: CryptoSuiteId) -> Result<(), IdentityError> { + if crypto_suite_id != v1_crypto_suite_id()? { + return Err(IdentityError::UnsupportedKeyWrapSuite); + } + Ok(()) +} + +fn validate_distribution_lifecycle(state: &AccountState) -> Result<(), IdentityError> { + match state.lifecycle() { + ProjectionLifecycle::Active + | ProjectionLifecycle::MigrationPending + | ProjectionLifecycle::MigrationDual => Ok(()), + ProjectionLifecycle::RecoveryPending => Err(IdentityError::RecoveryPending), + ProjectionLifecycle::Forked => Err(IdentityError::AccountForked), + ProjectionLifecycle::UpgradePending => Err(IdentityError::ProtocolUpgradeReadOnly), + ProjectionLifecycle::Retired => Err(IdentityError::AccountRetired), + } +} + +/// A fixed-size application group key which erases its bytes when dropped. +pub struct GroupKey(Zeroizing<[u8; GROUP_KEY_BYTES]>); + +impl GroupKey { + /// Take ownership of an exact 256-bit application group key. + pub fn new(bytes: [u8; GROUP_KEY_BYTES]) -> Self { + Self(Zeroizing::new(bytes)) + } + + /// Borrow the exact group-key bytes. + pub fn as_bytes(&self) -> &[u8; GROUP_KEY_BYTES] { + &self.0 + } +} + +impl fmt::Debug for GroupKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("GroupKey()") + } +} + +/// A reusable recipient X25519 secret which erases its bytes when dropped. +/// +/// This type is intentionally neither `Copy` nor `Clone`. +pub struct AgreementSecretKey(StaticSecret); + +impl AgreementSecretKey { + /// Take ownership of 32 bytes of X25519 secret-key material. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(StaticSecret::from(bytes)) + } + + /// Derive the public X25519 key corresponding to this secret. + pub fn public_key(&self) -> Result { + let public_key = PublicKey::from(&self.0); + AgreementPublicKey::x25519(public_key.to_bytes()) + } + + /// Derive a contributory X25519 shared secret for another crate-owned protocol. + pub(crate) fn diffie_hellman( + &self, + peer: AgreementPublicKey, + ) -> Result { + let peer = PublicKey::from(*peer.as_bytes()); + let shared_secret = self.0.diffie_hellman(&peer); + validate_contributory(&shared_secret)?; + Ok(shared_secret) + } +} + +impl fmt::Debug for AgreementSecretKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AgreementSecretKey()") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DistributionRecipient { + device_id: DeviceId, + agreement_public_key: AgreementPublicKey, + authorization_epoch: Epoch, +} + +/// Validated application-group membership at one authoritative account revision. +/// +/// Construction derives every device authorization and lifecycle from [`AccountState`]. +/// The caller supplies the complete application-defined group membership as device IDs; +/// the resulting private recipient records cannot be forged or lifecycle-labelled later. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupKeyDistributionSnapshot { + crypto_suite_id: CryptoSuiteId, + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + account_revision: AccountRevision, + recipients: Vec, +} + +impl GroupKeyDistributionSnapshot { + /// Validate and capture a complete application-group membership from account post-state. + /// + /// `expected_recipient_ids` is the application's complete membership decision for this + /// exact group and revision. Every named device must exist, be active, and already be + /// authorized at the state's exact epoch. Input order is not significant. Active, + /// migration-pending, and migration-dual projections are eligible; signature-suite + /// migration leaves the fixed v1 key-wrap KEM/KDF/AEAD profile unchanged. Recovery, + /// forked, upgraded/read-only, and retired projections fail before recipient processing. + pub fn from_post_state( + state: &AccountState, + application_id: ApplicationId, + group_id: GroupId, + group_key_epoch: GroupKeyEpoch, + mut expected_recipient_ids: Vec, + ) -> Result { + // Candidate and dual migration states intentionally remain eligible: the fixed + // v1 X25519/BLAKE3/XChaCha20-Poly1305 wrap suite does not use controller + // signature keys and is unchanged throughout the signature-suite migration. + validate_distribution_lifecycle(state)?; + if expected_recipient_ids.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "group key distribution membership", + }); + } + if expected_recipient_ids.len() > MAX_DEVICES { + return Err(IdentityError::limit( + "group key distribution membership", + expected_recipient_ids.len(), + MAX_DEVICES, + )); + } + expected_recipient_ids.sort_unstable(); + if expected_recipient_ids + .windows(2) + .any(|pair| pair[0] == pair[1]) + { + return Err(IdentityError::DuplicateElement { + resource: "group key distribution membership", + }); + } + + let mut recipients = Vec::with_capacity(expected_recipient_ids.len()); + for device_id in expected_recipient_ids { + let device = state + .devices() + .binary_search_by_key(&device_id, |candidate| candidate.id()) + .ok() + .map(|index| &state.devices()[index]) + .ok_or(IdentityError::DeviceNotAuthorized)?; + match device.lifecycle() { + ProjectedDeviceLifecycle::Active => {} + ProjectedDeviceLifecycle::Suspended => { + return Err(IdentityError::DeviceSuspended); + } + ProjectedDeviceLifecycle::Revoked => return Err(IdentityError::DeviceRevoked), + } + if device.authorization_epoch() > state.epoch() { + return Err(IdentityError::InvalidEpoch); + } + recipients.push(DistributionRecipient { + device_id, + agreement_public_key: device.descriptor().agreement_key(), + authorization_epoch: device.authorization_epoch(), + }); + } + + Ok(Self { + crypto_suite_id: v1_crypto_suite_id()?, + account_id: state.account_id(), + application_id, + group_id, + authorizing_account_epoch: state.epoch(), + group_key_epoch, + account_revision: state.revision_token(), + recipients, + }) + } + + /// Fixed cryptographic suite expected for the distribution. + pub const fn crypto_suite_id(&self) -> CryptoSuiteId { + self.crypto_suite_id + } + + /// Account owning the captured post-state. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Application namespace owning the group. + pub const fn application_id(&self) -> ApplicationId { + self.application_id + } + + /// Application-defined group identifier. + pub const fn group_id(&self) -> GroupId { + self.group_id + } + + /// Exact post-transition account epoch used for recipient selection. + pub const fn authorizing_account_epoch(&self) -> Epoch { + self.authorizing_account_epoch + } + + /// New application group-key epoch. + pub const fn group_key_epoch(&self) -> GroupKeyEpoch { + self.group_key_epoch + } + + /// Complete authoritative account revision, including the exact sorted head set. + pub const fn account_revision(&self) -> &AccountRevision { + &self.account_revision + } + + /// Complete sorted application-group membership captured by this snapshot. + pub fn expected_recipient_ids(&self) -> impl ExactSizeIterator + '_ { + self.recipients.iter().map(|recipient| recipient.device_id) + } + + fn recipient(&self, device_id: DeviceId) -> Result<&DistributionRecipient, IdentityError> { + self.recipients + .binary_search_by_key(&device_id, |recipient| recipient.device_id) + .ok() + .map(|index| &self.recipients[index]) + .ok_or(IdentityError::DeviceNotAuthorized) + } + + fn validate_header(&self, header: &GroupKeyWrapHeader) -> Result<(), IdentityError> { + validate_v1_crypto_suite(self.crypto_suite_id)?; + if header.crypto_suite_id != self.crypto_suite_id { + return Err(IdentityError::UnsupportedKeyWrapSuite); + } + if header.account_id != self.account_id { + return Err(IdentityError::AccountMismatch); + } + if header.authorizing_account_epoch != self.authorizing_account_epoch { + return Err(IdentityError::InvalidEpoch); + } + if header.application_id != self.application_id + || header.group_id != self.group_id + || header.group_key_epoch != self.group_key_epoch + { + return Err(IdentityError::InvalidRelationship { + resource: "group key wrap distribution context", + }); + } + Ok(()) + } + + fn validate_output(&self, wraps: &RecipientKeyWraps) -> Result<(), IdentityError> { + if wraps.as_slice().len() != self.recipients.len() + || !wraps + .as_slice() + .iter() + .map(WrappedGroupKey::recipient_device_id) + .eq(self.expected_recipient_ids()) + { + return Err(IdentityError::InvalidRelationship { + resource: "group key rotation snapshot output", + }); + } + for wrap in wraps.as_slice() { + self.validate_header(wrap.header())?; + let recipient = self.recipient(wrap.recipient_device_id())?; + wrap.header() + .validate_recipient_binding(recipient.device_id, recipient.agreement_public_key)?; + } + Ok(()) + } +} + +/// Revision-bound local result of one complete application group-key rotation. +/// +/// This artifact is intentionally not a canonical wire type. Persistence must accept +/// the complete artifact, revalidate its account revision immediately before an atomic +/// compare-and-swap, and persist its recipient wraps only as part of that transaction. +/// No constructor or consuming accessor exposes a bare, unbound rotation result. +#[derive(Debug)] +pub struct GroupKeyRotation { + crypto_suite_id: CryptoSuiteId, + account_revision: AccountRevision, + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + expected_recipient_ids: Vec, + recipient_key_wraps: RecipientKeyWraps, +} + +impl GroupKeyRotation { + fn from_snapshot( + snapshot: &GroupKeyDistributionSnapshot, + recipient_key_wraps: RecipientKeyWraps, + ) -> Result { + snapshot.validate_output(&recipient_key_wraps)?; + if snapshot.account_revision.account_id() != snapshot.account_id { + return Err(IdentityError::StorageCorruption); + } + Ok(Self { + crypto_suite_id: snapshot.crypto_suite_id, + account_revision: snapshot.account_revision.clone(), + account_id: snapshot.account_id, + application_id: snapshot.application_id, + group_id: snapshot.group_id, + authorizing_account_epoch: snapshot.authorizing_account_epoch, + group_key_epoch: snapshot.group_key_epoch, + expected_recipient_ids: snapshot.expected_recipient_ids().collect(), + recipient_key_wraps, + }) + } + + /// Fixed v1 wrap suite captured with this rotation. + pub const fn crypto_suite_id(&self) -> CryptoSuiteId { + self.crypto_suite_id + } + + /// Exact account revision that must still be current at persistence time. + pub const fn account_revision(&self) -> &AccountRevision { + &self.account_revision + } + + /// Account owning the rotation. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Application namespace owning the rotated key. + pub const fn application_id(&self) -> ApplicationId { + self.application_id + } + + /// Application-defined group whose key was rotated. + pub const fn group_id(&self) -> GroupId { + self.group_id + } + + /// Exact account epoch used to authorize the recipient membership. + pub const fn authorizing_account_epoch(&self) -> Epoch { + self.authorizing_account_epoch + } + + /// New application group-key epoch. + pub const fn group_key_epoch(&self) -> GroupKeyEpoch { + self.group_key_epoch + } + + /// Complete sorted recipient membership captured by the rotation. + pub fn expected_recipient_ids(&self) -> impl ExactSizeIterator + '_ { + self.expected_recipient_ids.iter().copied() + } + + /// Borrow the recipient wraps while retaining their revision-bound artifact. + pub const fn recipient_key_wraps(&self) -> &RecipientKeyWraps { + &self.recipient_key_wraps + } + + /// Verify that persistence still targets the exact authoritative account revision. + /// + /// A fork is reported distinctly so callers cannot treat an unresolved multi-head + /// projection as an ordinary compare-and-swap race. + pub fn validate_current_revision(&self, state: &AccountState) -> Result<(), IdentityError> { + if state.account_id() != self.account_id { + return Err(IdentityError::AccountMismatch); + } + validate_distribution_lifecycle(state)?; + if state.revision_token() != self.account_revision + || state.epoch() != self.authorizing_account_epoch + { + return Err(IdentityError::StaleRevision); + } + Ok(()) + } +} + +/// Domain-separated identifier of one device's exact public agreement-key binding. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct AgreementKeyId(Digest); + +impl AgreementKeyId { + /// Derive an identifier bound to both the recipient device and its agreement key. + pub fn derive( + device_id: DeviceId, + agreement_key: AgreementPublicKey, + ) -> Result { + let binding = encode_wire(&(device_id, agreement_key))?; + Ok(Self(hash_bytes(HashDomain::AgreementKey, &binding))) + } + + /// Algorithm-tagged digest bytes. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } +} + +impl fmt::Debug for AgreementKeyId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AgreementKeyId") + .field(&self.0) + .finish() + } +} + +impl fmt::Display for AgreementKeyId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl CanonicalCodec for AgreementKeyId { + const RESOURCE: &'static str = "agreement key identifier bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Exact 24-byte XChaCha20-Poly1305 nonce used for one recipient wrap. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct KeyWrapNonce([u8; 24]); + +impl KeyWrapNonce { + /// Construct an exact nonce. Freshness and uniqueness are producer/state invariants. + pub const fn new(bytes: [u8; 24]) -> Self { + Self(bytes) + } + + /// Exact nonce bytes. + pub const fn as_bytes(&self) -> &[u8; 24] { + &self.0 + } +} + +impl fmt::Debug for KeyWrapNonce { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("KeyWrapNonce") + .field(&"") + .finish() + } +} + +impl<'de> Deserialize<'de> for KeyWrapNonce { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self::new(<[u8; 24]>::deserialize(deserializer)?)) + } +} + +impl CanonicalCodec for KeyWrapNonce { + const RESOURCE: &'static str = "group key wrap nonce bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Canonical associated-data header for one recipient's wrapped application group key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GroupKeyWrapHeader { + protocol_version: ProtocolVersion, + crypto_suite_id: CryptoSuiteId, + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + recipient_device_id: DeviceId, + recipient_agreement_key_id: AgreementKeyId, + ephemeral_public_key: AgreementPublicKey, + nonce: KeyWrapNonce, + extensions: Extensions, +} + +impl GroupKeyWrapHeader { + /// Construct a header directly from the current recipient authorization. + #[allow(clippy::too_many_arguments)] + pub fn new_for_recipient( + crypto_suite_id: CryptoSuiteId, + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + recipient: &DeviceAuthorization, + ephemeral_public_key: AgreementPublicKey, + nonce: KeyWrapNonce, + extensions: Extensions, + ) -> Result { + if recipient.authorization_epoch() > authorizing_account_epoch { + return Err(IdentityError::InvalidEpoch); + } + let recipient_key = recipient.descriptor().agreement_key(); + Self::new_for_binding( + crypto_suite_id, + account_id, + application_id, + group_id, + authorizing_account_epoch, + group_key_epoch, + recipient.device_id(), + recipient_key, + ephemeral_public_key, + nonce, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn new_for_binding( + crypto_suite_id: CryptoSuiteId, + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + recipient_device_id: DeviceId, + recipient_agreement_key: AgreementPublicKey, + ephemeral_public_key: AgreementPublicKey, + nonce: KeyWrapNonce, + extensions: Extensions, + ) -> Result { + if ephemeral_public_key == recipient_agreement_key { + return Err(IdentityError::InvalidRelationship { + resource: "ephemeral and recipient agreement keys", + }); + } + let recipient_agreement_key_id = + AgreementKeyId::derive(recipient_device_id, recipient_agreement_key)?; + Self::from_fields( + crypto_suite_id, + account_id, + application_id, + group_id, + authorizing_account_epoch, + group_key_epoch, + recipient_device_id, + recipient_agreement_key_id, + ephemeral_public_key, + nonce, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_fields( + crypto_suite_id: CryptoSuiteId, + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + recipient_device_id: DeviceId, + recipient_agreement_key_id: AgreementKeyId, + ephemeral_public_key: AgreementPublicKey, + nonce: KeyWrapNonce, + extensions: Extensions, + ) -> Result { + validate_v1_crypto_suite(crypto_suite_id)?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + crypto_suite_id, + account_id, + application_id, + group_id, + authorizing_account_epoch, + group_key_epoch, + recipient_device_id, + recipient_agreement_key_id, + ephemeral_public_key, + nonce, + extensions, + }) + } + + /// Cryptographic suite governing KEM, KDF, and AEAD interpretation. + pub const fn crypto_suite_id(&self) -> CryptoSuiteId { + self.crypto_suite_id + } + + /// Account whose state authorizes the recipient set. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Application namespace owning the group. + pub const fn application_id(&self) -> ApplicationId { + self.application_id + } + + /// Application-defined group identifier. + pub const fn group_id(&self) -> GroupId { + self.group_id + } + + /// Account epoch against which the recipient authorization was evaluated. + pub const fn authorizing_account_epoch(&self) -> Epoch { + self.authorizing_account_epoch + } + + /// Independent application group-key epoch. + pub const fn group_key_epoch(&self) -> GroupKeyEpoch { + self.group_key_epoch + } + + /// Intended recipient device. + pub const fn recipient_device_id(&self) -> DeviceId { + self.recipient_device_id + } + + /// Recipient agreement-key binding identifier. + pub const fn recipient_agreement_key_id(&self) -> AgreementKeyId { + self.recipient_agreement_key_id + } + + /// Fresh X25519 public key generated for this one recipient wrap. + pub const fn ephemeral_public_key(&self) -> AgreementPublicKey { + self.ephemeral_public_key + } + + /// Fresh XChaCha20-Poly1305 nonce generated for this one recipient wrap. + pub const fn nonce(&self) -> KeyWrapNonce { + self.nonce + } + + /// Authenticated associated-data extension fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Validate this wire header against a state-supplied recipient authorization. + pub fn validate_recipient(&self, recipient: &DeviceAuthorization) -> Result<(), IdentityError> { + self.validate_recipient_binding( + recipient.device_id(), + recipient.descriptor().agreement_key(), + ) + } + + fn validate_recipient_binding( + &self, + recipient_device_id: DeviceId, + recipient_agreement_key: AgreementPublicKey, + ) -> Result<(), IdentityError> { + if self.recipient_device_id != recipient_device_id { + return Err(IdentityError::InvalidRelationship { + resource: "group key wrap recipient device", + }); + } + let expected_key_id = AgreementKeyId::derive(recipient_device_id, recipient_agreement_key)?; + if self.recipient_agreement_key_id != expected_key_id { + return Err(IdentityError::InvalidIdentifier { + resource: "recipient agreement key", + }); + } + if self.ephemeral_public_key == recipient_agreement_key { + return Err(IdentityError::InvalidRelationship { + resource: "ephemeral and recipient agreement keys", + }); + } + Ok(()) + } + + fn same_distribution_context(&self, other: &Self) -> bool { + self.crypto_suite_id == other.crypto_suite_id + && self.account_id == other.account_id + && self.application_id == other.application_id + && self.group_id == other.group_id + && self.authorizing_account_epoch == other.authorizing_account_epoch + && self.group_key_epoch == other.group_key_epoch + && self.extensions == other.extensions + } +} + +impl<'de> Deserialize<'de> for GroupKeyWrapHeader { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + crypto_suite_id: CryptoSuiteId, + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + recipient_device_id: DeviceId, + recipient_agreement_key_id: AgreementKeyId, + ephemeral_public_key: AgreementPublicKey, + nonce: KeyWrapNonce, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::from_fields( + wire.crypto_suite_id, + wire.account_id, + wire.application_id, + wire.group_id, + wire.authorizing_account_epoch, + wire.group_key_epoch, + wire.recipient_device_id, + wire.recipient_agreement_key_id, + wire.ephemeral_public_key, + wire.nonce, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for GroupKeyWrapHeader { + const RESOURCE: &'static str = "group key wrap header bytes"; + const MAX_ENCODED_BYTES: usize = MAX_KEY_WRAP_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +fn derive_wrapping_key( + shared_secret: &SharedSecret, + ephemeral_public_key: AgreementPublicKey, + recipient_public_key: AgreementPublicKey, +) -> Zeroizing<[u8; GROUP_KEY_BYTES]> { + let mut key_material = Zeroizing::new([0_u8; KEY_WRAP_KDF_MATERIAL_BYTES]); + key_material[..32].copy_from_slice(shared_secret.as_bytes()); + key_material[32..64].copy_from_slice(ephemeral_public_key.as_bytes()); + key_material[64..].copy_from_slice(recipient_public_key.as_bytes()); + Zeroizing::new(blake3::derive_key(KEY_WRAP_KDF_CONTEXT, &key_material[..])) +} + +fn wrap_associated_data( + header: &GroupKeyWrapHeader, + outer_extensions: &Extensions, +) -> Result, IdentityError> { + encode_wire(&(header, outer_extensions)) +} + +#[cfg(any(feature = "os-rng", test))] +fn generate_wrap_randomness_with( + mut fill: F, +) -> Result<(Zeroizing<[u8; 32]>, [u8; 24]), IdentityError> +where + F: FnMut(&mut [u8]) -> Result<(), E>, +{ + let mut ephemeral_secret = Zeroizing::new([0_u8; 32]); + fill(&mut ephemeral_secret[..]).map_err(|_| IdentityError::EntropyUnavailable)?; + let mut nonce = [0_u8; 24]; + fill(&mut nonce).map_err(|_| IdentityError::EntropyUnavailable)?; + Ok((ephemeral_secret, nonce)) +} + +fn validate_contributory(shared_secret: &SharedSecret) -> Result<(), IdentityError> { + if !shared_secret.was_contributory() { + return Err(IdentityError::InvalidPublicKey { + kind: crate::AlgorithmKind::Agreement, + }); + } + Ok(()) +} + +fn wrap_group_key_with_material( + snapshot: &GroupKeyDistributionSnapshot, + group_key: &GroupKey, + recipient: &DistributionRecipient, + ephemeral_secret_bytes: Zeroizing<[u8; 32]>, + nonce_bytes: [u8; 24], +) -> Result { + validate_v1_crypto_suite(snapshot.crypto_suite_id)?; + if recipient.authorization_epoch > snapshot.authorizing_account_epoch { + return Err(IdentityError::InvalidEpoch); + } + let ephemeral_secret = StaticSecret::from(*ephemeral_secret_bytes); + let ephemeral_public = PublicKey::from(&ephemeral_secret); + let ephemeral_public_key = AgreementPublicKey::x25519(ephemeral_public.to_bytes())?; + let recipient_public_key = recipient.agreement_public_key; + let recipient_public = PublicKey::from(*recipient_public_key.as_bytes()); + let shared_secret = ephemeral_secret.diffie_hellman(&recipient_public); + validate_contributory(&shared_secret)?; + let wrapping_key = + derive_wrapping_key(&shared_secret, ephemeral_public_key, recipient_public_key); + let nonce = KeyWrapNonce::new(nonce_bytes); + let header = GroupKeyWrapHeader::new_for_binding( + snapshot.crypto_suite_id, + snapshot.account_id, + snapshot.application_id, + snapshot.group_id, + snapshot.authorizing_account_epoch, + snapshot.group_key_epoch, + recipient.device_id, + recipient_public_key, + ephemeral_public_key, + nonce, + Extensions::default(), + )?; + let outer_extensions = Extensions::default(); + let associated_data = wrap_associated_data(&header, &outer_extensions)?; + let cipher = XChaCha20Poly1305::new(&Key::from(*wrapping_key)); + let ciphertext = cipher + .encrypt( + &XNonce::from(nonce_bytes), + Payload { + msg: group_key.as_bytes(), + aad: &associated_data, + }, + ) + .map_err(|_| IdentityError::KeyWrapAuthenticationFailed)?; + WrappedGroupKey::new(header, ciphertext, outer_extensions) +} + +/// Authenticate and unwrap one exact 32-byte application group key. +/// +/// Wrong secrets, modified ciphertext, and modified associated data all return +/// the same non-oracular authentication error after public context validation. +pub fn unwrap_group_key( + snapshot: &GroupKeyDistributionSnapshot, + wrapped: &WrappedGroupKey, + recipient_secret: &AgreementSecretKey, +) -> Result { + snapshot.validate_header(wrapped.header())?; + let recipient = snapshot.recipient(wrapped.recipient_device_id())?; + wrapped + .header() + .validate_recipient_binding(recipient.device_id, recipient.agreement_public_key)?; + + let ephemeral_public_key = wrapped.header().ephemeral_public_key(); + let ephemeral_public = PublicKey::from(*ephemeral_public_key.as_bytes()); + let shared_secret = recipient_secret.0.diffie_hellman(&ephemeral_public); + validate_contributory(&shared_secret)?; + let recipient_public_key = recipient.agreement_public_key; + let wrapping_key = + derive_wrapping_key(&shared_secret, ephemeral_public_key, recipient_public_key); + let associated_data = wrap_associated_data(wrapped.header(), wrapped.extensions())?; + let cipher = XChaCha20Poly1305::new(&Key::from(*wrapping_key)); + let plaintext = cipher + .decrypt( + &XNonce::from(*wrapped.header().nonce().as_bytes()), + Payload { + msg: wrapped.ciphertext(), + aad: &associated_data, + }, + ) + .map(Zeroizing::new) + .map_err(|_| IdentityError::KeyWrapAuthenticationFailed)?; + let group_key_bytes: [u8; GROUP_KEY_BYTES] = plaintext + .as_slice() + .try_into() + .map_err(|_| IdentityError::KeyWrapAuthenticationFailed)?; + Ok(GroupKey::new(group_key_bytes)) +} + +/// Produce a revision-bound complete rotation using an explicit cryptographic RNG. +/// +/// All post-state recipient records are validated before randomness is consumed. +/// Each recipient then receives an independent ephemeral secret and nonce, and +/// the private artifact constructor rejects reuse or incomplete recipient coverage. +pub fn rotate_group_key_with_rng( + snapshot: &GroupKeyDistributionSnapshot, + group_key: &GroupKey, + rng: &mut R, +) -> Result { + let mut wraps = Vec::with_capacity(snapshot.recipients.len()); + for recipient in &snapshot.recipients { + let mut ephemeral_secret = Zeroizing::new([0_u8; 32]); + rng.fill_bytes(&mut ephemeral_secret[..]); + let mut nonce = [0_u8; 24]; + rng.fill_bytes(&mut nonce); + wraps.push(wrap_group_key_with_material( + snapshot, + group_key, + recipient, + ephemeral_secret, + nonce, + )?); + } + let wraps = RecipientKeyWraps::new(wraps)?; + GroupKeyRotation::from_snapshot(snapshot, wraps) +} + +/// Produce a revision-bound complete rotation using operating-system entropy. +/// +/// Entropy failure aborts the complete operation without returning a partial artifact. +#[cfg(feature = "os-rng")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] +pub fn rotate_group_key( + snapshot: &GroupKeyDistributionSnapshot, + group_key: &GroupKey, +) -> Result { + let mut wraps = Vec::with_capacity(snapshot.recipients.len()); + for recipient in &snapshot.recipients { + let (ephemeral_secret, nonce) = generate_wrap_randomness_with(getrandom::fill)?; + wraps.push(wrap_group_key_with_material( + snapshot, + group_key, + recipient, + ephemeral_secret, + nonce, + )?); + } + let wraps = RecipientKeyWraps::new(wraps)?; + GroupKeyRotation::from_snapshot(snapshot, wraps) +} + +/// One bounded ciphertext carrying a group key to the recipient named by its header. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WrappedGroupKey { + header: GroupKeyWrapHeader, + ciphertext: BoundedBytes, + extensions: Extensions, +} + +impl WrappedGroupKey { + /// Construct one recipient wrap and enforce both ciphertext and complete-envelope bounds. + pub fn new( + header: GroupKeyWrapHeader, + ciphertext: Vec, + extensions: Extensions, + ) -> Result { + if ciphertext.len() != KEY_WRAP_CIPHERTEXT_BYTES { + return Err(IdentityError::InvalidRelationship { + resource: "group key wrap ciphertext length", + }); + } + let ciphertext = BoundedBytes::new("group key wrap ciphertext bytes", ciphertext)?; + extensions.validate_critical(&[])?; + let wrapped = Self { + header, + ciphertext, + extensions, + }; + let encoded_len = encode_wire(&wrapped)?.len(); + if encoded_len > MAX_KEY_WRAP_BYTES { + return Err(IdentityError::limit( + "wrapped group key bytes", + encoded_len, + MAX_KEY_WRAP_BYTES, + )); + } + Ok(wrapped) + } + + /// Exact AEAD associated-data header. + pub const fn header(&self) -> &GroupKeyWrapHeader { + &self.header + } + + /// Recipient device named by the header. + pub const fn recipient_device_id(&self) -> DeviceId { + self.header.recipient_device_id + } + + /// Exact bounded AEAD ciphertext. + pub fn ciphertext(&self) -> &[u8] { + self.ciphertext.as_slice() + } + + /// Authenticated forward-compatible fields outside the associated-data header. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Derive the identifier of this complete canonical recipient wrap. + pub fn group_key_wrap_id(&self) -> Result { + GroupKeyWrapId::derive(self) + } +} + +impl<'de> Deserialize<'de> for WrappedGroupKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + header: GroupKeyWrapHeader, + ciphertext: BoundedBytes, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.header, wire.ciphertext.into_vec(), wire.extensions) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for WrappedGroupKey { + const RESOURCE: &'static str = "wrapped group key bytes"; + const MAX_ENCODED_BYTES: usize = MAX_KEY_WRAP_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Canonically sorted, duplicate-free recipient wraps for one group-key distribution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecipientKeyWraps(BoundedVec); + +impl RecipientKeyWraps { + /// Construct a nonempty set sorted by recipient `DeviceId`. + pub fn new(mut wraps: Vec) -> Result { + if wraps.len() > MAX_DEVICES { + return Err(IdentityError::limit( + "recipient key wraps", + wraps.len(), + MAX_DEVICES, + )); + } + wraps.sort_unstable_by_key(WrappedGroupKey::recipient_device_id); + Self::from_fields(wraps) + } + + fn from_fields(wraps: Vec) -> Result { + if wraps.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "recipient key wraps", + }); + } + let wraps = BoundedVec::new("recipient key wraps", wraps)?; + let first = &wraps.as_slice()[0]; + for pair in wraps.as_slice().windows(2) { + if pair[0].recipient_device_id() == pair[1].recipient_device_id() { + return Err(IdentityError::DuplicateElement { + resource: "recipient key wrap devices", + }); + } + if pair[0].recipient_device_id() > pair[1].recipient_device_id() { + return Err(IdentityError::NonCanonical); + } + } + for wrap in wraps.as_slice() { + if !first.header.same_distribution_context(&wrap.header) { + return Err(IdentityError::InvalidRelationship { + resource: "recipient key wrap distribution context", + }); + } + } + for (index, wrap) in wraps.as_slice().iter().enumerate() { + for other in &wraps.as_slice()[index.saturating_add(1)..] { + if wrap.header.ephemeral_public_key == other.header.ephemeral_public_key { + return Err(IdentityError::DuplicateElement { + resource: "recipient key wrap ephemeral public keys", + }); + } + if wrap.header.nonce == other.header.nonce { + return Err(IdentityError::DuplicateElement { + resource: "recipient key wrap nonces", + }); + } + } + } + + let set = Self(wraps); + let encoded_len = encode_wire(&set)?.len(); + if encoded_len > MAX_ENCODED_OBJECT_BYTES { + return Err(IdentityError::limit( + "recipient key wrap set bytes", + encoded_len, + MAX_ENCODED_OBJECT_BYTES, + )); + } + Ok(set) + } + + /// Borrow wraps in canonical recipient order. + pub fn as_slice(&self) -> &[WrappedGroupKey] { + self.0.as_slice() + } +} + +impl<'de> Deserialize<'de> for RecipientKeyWraps { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wraps = BoundedVec::::deserialize(deserializer)?; + Self::from_fields(wraps.into_vec()).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for RecipientKeyWraps { + const RESOURCE: &'static str = "recipient key wrap set bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ENCODED_OBJECT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::generate_wrap_randomness_with; + use crate::IdentityError; + + #[test] + fn fallible_entropy_failure_is_reported_without_fallback() { + let result = generate_wrap_randomness_with(|_| Err(())); + assert!(matches!(result, Err(IdentityError::EntropyUnavailable))); + + let calls = Cell::new(0_u8); + let result = generate_wrap_randomness_with(|bytes| { + calls.set( + calls + .get() + .checked_add(1) + .expect("test makes exactly two calls"), + ); + if calls.get() == 1 { + bytes.fill(0x5a); + Ok(()) + } else { + Err(()) + } + }); + assert!(matches!(result, Err(IdentityError::EntropyUnavailable))); + assert_eq!(calls.get(), 2); + } +} diff --git a/protocols/krikos-identity/src/keys.rs b/protocols/krikos-identity/src/keys.rs new file mode 100644 index 00000000000..d401b63aa9b --- /dev/null +++ b/protocols/krikos-identity/src/keys.rs @@ -0,0 +1,542 @@ +//! Canonical controller, provider, and independently keyed device descriptors. + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + AgreementPublicKey, ControllerId, ControllerWeight, DeviceId, Extensions, IdentityError, + OperationKind, ProtocolVersion, ProviderId, SigningPublicKey, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::MAX_POLICY_RULES, + schema::BoundedVec, +}; + +/// Stable v1 account-controller classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ControllerClass { + /// A controller kept on a trusted personal device. + PersonalDevice, + /// A dedicated hardware security key. + HardwareSecurityKey, + /// An offline recovery controller. + OfflineRecovery, + /// A controller operated by an explicit recovery guardian. + GuardianAccount, + /// An institutional or threshold-service controller. + Institutional, +} + +impl ControllerClass { + /// Stable v1 wire codepoint. + pub const fn code(self) -> u16 { + match self { + Self::PersonalDevice => 1, + Self::HardwareSecurityKey => 2, + Self::OfflineRecovery => 3, + Self::GuardianAccount => 4, + Self::Institutional => 5, + } + } + + fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::PersonalDevice), + 2 => Ok(Self::HardwareSecurityKey), + 3 => Ok(Self::OfflineRecovery), + 4 => Ok(Self::GuardianAccount), + 5 => Ok(Self::Institutional), + unsupported => Err(IdentityError::UnsupportedCodepoint { + registry: "controller class", + code: unsupported, + }), + } + } +} + +impl Serialize for ControllerClass { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ControllerClass { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_code(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ControllerClass { + const RESOURCE: &'static str = "controller class bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Immutable operation restrictions carried by one controller descriptor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControllerScope { + kind: ControllerScopeKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ControllerScopeKind { + AllV1Operations, + Operations(Vec), +} + +impl ControllerScope { + /// Construct the frozen all-v1-operations scope. + pub const fn all_v1_operations() -> Self { + Self { + kind: ControllerScopeKind::AllV1Operations, + } + } + + /// Validate, sort, and construct an explicit operation set. + pub fn operations(mut operations: Vec) -> Result { + operations.sort_unstable_by_key(|operation| operation.code()); + Self::from_sorted_operations(operations) + } + + /// Borrow the explicit operation set, or `None` for all v1 operations. + pub fn as_operations(&self) -> Option<&[OperationKind]> { + match &self.kind { + ControllerScopeKind::AllV1Operations => None, + ControllerScopeKind::Operations(operations) => Some(operations), + } + } + + /// Whether this immutable scope permits the operation. + pub fn allows(&self, operation: OperationKind) -> bool { + match &self.kind { + ControllerScopeKind::AllV1Operations => true, + ControllerScopeKind::Operations(operations) => operations + .binary_search_by_key(&operation.code(), |candidate| candidate.code()) + .is_ok(), + } + } + + fn from_sorted_operations(operations: Vec) -> Result { + if operations.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "controller operation scope", + }); + } + if operations.len() > MAX_POLICY_RULES { + return Err(IdentityError::limit( + "controller operation scope", + operations.len(), + MAX_POLICY_RULES, + )); + } + for pair in operations.windows(2) { + if pair[0].code() == pair[1].code() { + return Err(IdentityError::DuplicateElement { + resource: "controller operation scope", + }); + } + if pair[0].code() > pair[1].code() { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self { + kind: ControllerScopeKind::Operations(operations), + }) + } +} + +impl Serialize for ControllerScope { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.kind { + ControllerScopeKind::AllV1Operations => { + (1_u16, &[] as &[OperationKind]).serialize(serializer) + } + ControllerScopeKind::Operations(operations) => { + (2_u16, operations.as_slice()).serialize(serializer) + } + } + } +} + +impl<'de> Deserialize<'de> for ControllerScope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, operations) = + <(u16, BoundedVec)>::deserialize(deserializer)?; + let operations = operations.into_vec(); + match code { + 1 if operations.is_empty() => Ok(Self::all_v1_operations()), + 1 => Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "all-v1 controller scope payload", + })), + 2 => Self::from_sorted_operations(operations).map_err(de::Error::custom), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "controller scope", + code: unsupported, + })), + } + } +} + +impl CanonicalCodec for ControllerScope { + const RESOURCE: &'static str = "controller scope bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Versioned public descriptor for one weighted account controller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControllerDescriptor { + protocol_version: ProtocolVersion, + signing_key: SigningPublicKey, + class: ControllerClass, + weight: ControllerWeight, + scope: ControllerScope, + extensions: Extensions, +} + +impl ControllerDescriptor { + /// Construct a canonical v1 controller descriptor. + pub fn new( + signing_key: SigningPublicKey, + class: ControllerClass, + weight: ControllerWeight, + scope: ControllerScope, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + signing_key, + class, + weight, + scope, + extensions, + }) + } + + /// Derive the stable descriptor identifier. + pub fn id(&self) -> Result { + ControllerId::derive(self) + } + + /// Public signing key used for account-control approvals. + pub const fn signing_key(&self) -> SigningPublicKey { + self.signing_key + } + + /// Controller class used by class selectors. + pub const fn class(&self) -> ControllerClass { + self.class + } + + /// Nonzero controller weight. + pub const fn weight(&self) -> ControllerWeight { + self.weight + } + + /// Immutable v1 operation scope. + pub const fn scope(&self) -> &ControllerScope { + &self.scope + } + + /// Signed forward-compatible extensions. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl Serialize for ControllerDescriptor { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ( + self.protocol_version, + self.signing_key, + self.class, + self.weight, + &self.scope, + &self.extensions, + ) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ControllerDescriptor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (_version, signing_key, class, weight, scope, extensions) = + <( + ProtocolVersion, + SigningPublicKey, + ControllerClass, + ControllerWeight, + ControllerScope, + Extensions, + )>::deserialize(deserializer)?; + Self::new(signing_key, class, weight, scope, extensions).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ControllerDescriptor { + const RESOURCE: &'static str = "controller descriptor bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Versioned self-certifying transparency-provider descriptor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderDescriptor { + protocol_version: ProtocolVersion, + signing_key: SigningPublicKey, + extensions: Extensions, +} + +impl ProviderDescriptor { + /// Construct a canonical v1 provider descriptor. + pub fn new( + signing_key: SigningPublicKey, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + signing_key, + extensions, + }) + } + + /// Derive the stable provider identifier. + pub fn id(&self) -> Result { + ProviderId::derive(self) + } + + /// Provider signing key. + pub const fn signing_key(&self) -> SigningPublicKey { + self.signing_key + } + + /// Signed forward-compatible extensions. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl Serialize for ProviderDescriptor { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.protocol_version, self.signing_key, &self.extensions).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProviderDescriptor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (_version, signing_key, extensions) = + <(ProtocolVersion, SigningPublicKey, Extensions)>::deserialize(deserializer)?; + Self::new(signing_key, extensions).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ProviderDescriptor { + const RESOURCE: &'static str = "provider descriptor bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Semantically distinct Ed25519 key used only for Krikos endpoint identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EndpointPublicKey(SigningPublicKey); + +impl EndpointPublicKey { + /// Wrap a validated signing key in the endpoint-key role. + pub const fn new(key: SigningPublicKey) -> Self { + Self(key) + } + + /// Exact endpoint public key. + pub const fn as_signing_key(self) -> SigningPublicKey { + self.0 + } +} + +impl Serialize for EndpointPublicKey { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for EndpointPublicKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self::new(SigningPublicKey::deserialize(deserializer)?)) + } +} + +impl CanonicalCodec for EndpointPublicKey { + const RESOURCE: &'static str = "endpoint public key bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Independently generated public key roles for one replaceable device identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceDescriptor { + protocol_version: ProtocolVersion, + application_signing_key: SigningPublicKey, + agreement_key: AgreementPublicKey, + endpoint_key: EndpointPublicKey, + extensions: Extensions, +} + +impl DeviceDescriptor { + /// Construct a device descriptor and enforce separation between all key roles. + pub fn new( + application_signing_key: SigningPublicKey, + agreement_key: AgreementPublicKey, + endpoint_key: EndpointPublicKey, + extensions: Extensions, + ) -> Result { + let application_bytes = application_signing_key.as_bytes(); + let agreement_bytes = agreement_key.as_bytes(); + let endpoint_signing_key = endpoint_key.as_signing_key(); + let endpoint_bytes = endpoint_signing_key.as_bytes(); + if application_bytes == endpoint_bytes + || application_bytes == agreement_bytes + || endpoint_bytes == agreement_bytes + { + return Err(IdentityError::InvalidRelationship { + resource: "device public-key role separation", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + application_signing_key, + agreement_key, + endpoint_key, + extensions, + }) + } + + /// Derive the stable device identifier from all public key roles. + pub fn id(&self) -> Result { + DeviceId::derive(self) + } + + /// Application-event signing key. + pub const fn application_signing_key(&self) -> SigningPublicKey { + self.application_signing_key + } + + /// Group-key agreement key. + pub const fn agreement_key(&self) -> AgreementPublicKey { + self.agreement_key + } + + /// Krikos transport endpoint key. + pub const fn endpoint_key(&self) -> EndpointPublicKey { + self.endpoint_key + } + + /// Signed forward-compatible extensions. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl Serialize for DeviceDescriptor { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ( + self.protocol_version, + self.application_signing_key, + self.agreement_key, + self.endpoint_key, + &self.extensions, + ) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DeviceDescriptor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (_version, signing_key, agreement_key, endpoint_key, extensions) = + <( + ProtocolVersion, + SigningPublicKey, + AgreementPublicKey, + EndpointPublicKey, + Extensions, + )>::deserialize(deserializer)?; + Self::new(signing_key, agreement_key, endpoint_key, extensions).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for DeviceDescriptor { + const RESOURCE: &'static str = "device descriptor bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} diff --git a/protocols/krikos-identity/src/lib.rs b/protocols/krikos-identity/src/lib.rs new file mode 100644 index 00000000000..e81160eaf04 --- /dev/null +++ b/protocols/krikos-identity/src/lib.rs @@ -0,0 +1,280 @@ +//! Distributed multi-device identity and authorization for Krikos. +//! +//! `krikos-identity` keeps a stable account identity separate from replaceable endpoint, +//! controller, and application-device keys. Its default-feature core verifies canonical v1 wire +//! objects, projects an append-only account-control history, detects forks, evaluates structural +//! capabilities, and returns explicit effects without reading a clock, network, database, or +//! random-number source. +//! +//! # Authority model +//! +//! An authenticated Krikos endpoint proves control of one transport key; it is an authorized +//! account device only after that key is matched to an active device at a verified checkpoint. +//! Likewise, transparency providers can retain, timestamp, and prove inclusion of authorized +//! records but cannot create account authority. [`AccountState`] applies each transition under the +//! exact previous policy and admission evidence, while [`AccountStore`] implementations retain the +//! canonical source history and idempotent operational effects. +//! +//! Offline decisions are always relative to a named checkpoint and epoch. Callers that require +//! current status must supply policy-sufficient provider evidence and explicit verifier time; +//! absence of that evidence is not converted into a global-validity claim. Conflicting histories +//! enter a forked lifecycle and require an explicitly authorized resolution. +//! +//! # Features +//! +//! - The default feature set is empty and contains the runtime-independent, deterministic protocol +//! core. APIs that accept caller-owned randomness remain available here. +//! - `fs-store` enables the redb-backed account source, checkpoint, and effect store. +//! - `provider-store` enables redb-backed provider generations, auditing, and operational journals. +//! - `net` enables bounded Tokio/Krikos framing and protocol adapters. +//! - `os-rng` enables only convenience APIs that obtain fresh secrets from the operating system. +//! +//! Applications normally start with [`AccountGenesis`], reconstruct or load an [`AccountState`], +//! verify and commit [`AuthorizedEvent`] values through an [`AccountStore`], and make application +//! decisions only from a verified checkpoint/capability basis. All public wire decoding goes +//! through the bounded, canonical [`CanonicalWire`] contract. +//! +//! The exact v1 serialization profile and codepoints are documented in the crate README. Provider +//! recovery/compaction procedures and the security/deployment guide live in the crate's `docs/` +//! directory. This crate is not yet a stable protocol release; external audit, independent +//! interoperability, and production provider diversity remain explicit release gates. +#![forbid(unsafe_code)] +#![deny(missing_docs, rustdoc::broken_intra_doc_links, unreachable_pub)] +#![cfg_attr(krikos_docsrs, feature(doc_cfg))] +#![cfg_attr(not(test), deny(clippy::unwrap_used))] + +mod application; +mod audit; +mod capability; +mod capability_verifier; +mod checkpoint; +mod codec; +mod crypto_migration; +mod device; +mod error; +mod event; +mod extension; +mod freshness; +mod genesis; +mod key_wrap; +mod keys; +/// Protocol-wide resource limits. +pub mod limits; +pub mod merkle; +mod names; +#[cfg(feature = "net")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "net")))] +pub mod net; +mod operations; +mod pairing; +mod policy; +mod presence; +mod privacy; +mod proposal; +mod provider; +mod publication; +mod recovery; +#[cfg(any(feature = "fs-store", feature = "provider-store"))] +mod redb_guard; +mod schema; +mod social; +mod state; +mod store; +mod sync; +mod transparency; +/// Runtime-independent transport, discovery, gossip, and blob contracts. +pub mod transport; +mod types; +mod verifier; + +pub use application::{ + ApplicationAuthorizationView, ApplicationDeviceStatus, ApplicationEventBody, + ApplicationEventCounter, SignedApplicationEvent, verify_application_event, +}; +#[cfg(feature = "provider-store")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "provider-store")))] +pub use audit::RedbProviderAuditStore; +pub use audit::{ + DurableProviderAuditor, MemoryProviderAuditStore, ProviderAuditAppend, ProviderAuditArtifact, + ProviderAuditArtifactKind, ProviderAuditCursor, ProviderAuditRecord, ProviderAuditSnapshot, + ProviderAuditStatus, ProviderAuditStore, +}; +pub use capability::{ + AuthorizationContext, CapabilityAction, CapabilityConstraint, CapabilityGrant, + CapabilityNamespace, CapabilityRoot, DelegationBody, DelegationChain, DelegationPermission, + MAX_RESOURCE_PATH_SEGMENTS, ResourcePath, ResourceSegment, ResourceSelector, SignedDelegation, +}; +pub use capability_verifier::{ + CapabilityDecision, CapabilityDenialReason, CapabilityDeviceStatus, CapabilityProof, + CapabilityRequest, CapabilityStateView, DelegationSignatureStatus, DelegationSignatureVerifier, + evaluate_capability, +}; +pub use checkpoint::{ + AccountLifecycle, CHECKPOINT_AUTHORIZED_DEVICE_TYPE_TAG, CHECKPOINT_REVOKED_DEVICE_TYPE_TAG, + CHECKPOINT_STATE_CONTROLLER_TYPE_TAG, CHECKPOINT_STATE_DEVICE_TYPE_TAG, + CHECKPOINT_STATE_METADATA_TYPE_TAG, CheckpointAuthorization, CheckpointBody, + CheckpointMerkleSets, CheckpointTransitionKind, InclusionReceipt, ProviderCheckpointBundle, + ProviderCheckpointLineage, ProviderEquivocationEvidence, ProviderHeadBody, + ProviderLogEntryBody, ProviderLogSubject, ProviderReceipts, SignedCheckpoint, + SignedProviderHead, TransitionCheckpointWitness, TrustedCheckpointBootstrap, + VerifiedCheckpoint, bootstrap_checkpoint_from_genesis, bootstrap_checkpoint_from_prior, + build_checkpoint_body, build_checkpoint_merkle_sets, + build_provider_checkpoint_bundle_from_genesis, build_provider_checkpoint_bundle_from_prior, + verify_checkpoint, verify_provider_head_progression, +}; +pub use codec::CanonicalWire; +pub use crypto_migration::{ + ActivateCryptoMigration, BeginCryptoMigration, ControllerKeyBinding, ControllerKeyBindingProof, + ControllerKeyBindingProofSet, CryptoMigrationBody, CryptoSuiteDescriptor, ProtocolUpgrade, + RetireAccount, RetireCryptoSuite, RetireCryptoSuiteMode, UpgradeCompatibility, +}; +pub use device::{ + BlindedMetadataCommitment, DeviceAuthorization, DeviceAuthorizationUpdate, DeviceClass, + DeviceMetadataUpdate, DeviceUpdate, ReinstateDevice, RevokeDevice, RotateDeviceKeys, + SuspendDevice, +}; +pub use error::{AlgorithmKind, IdentityError}; +pub use event::{ + AccountOperation, AdmissionEvidence, AuthorizedEvent, ControllerApprovalBody, + ControllerApprovals, DelayEvidence, EventBody, EventIntentApprovalBody, EventIntentApprovals, + EventPredecessors, FreshnessEvidence, KeyedSignature, SignedControllerApproval, + SignedEventIntentApproval, +}; +pub use extension::{Extension, Extensions}; +pub use freshness::{FreshnessDecision, evaluate_freshness}; +pub use genesis::AccountGenesis; +#[cfg(feature = "os-rng")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] +pub use key_wrap::rotate_group_key; +pub use key_wrap::{ + AgreementKeyId, AgreementSecretKey, GroupKey, GroupKeyDistributionSnapshot, GroupKeyRotation, + GroupKeyWrapHeader, KeyWrapNonce, RecipientKeyWraps, WrappedGroupKey, + rotate_group_key_with_rng, unwrap_group_key, +}; +pub use keys::{ + ControllerClass, ControllerDescriptor, ControllerScope, DeviceDescriptor, EndpointPublicKey, + ProviderDescriptor, +}; +pub use names::{ + NameAuthorityContext, NameCandidateSet, NameClaimBody, NameResolver, NormalizedName, + SignedNameClaim, TofuDecision, TofuObservation, VerifiedNameCandidates, VerifiedNameClaim, + evaluate_name_tofu, resolve_name_candidates, verify_name_candidates, verify_name_claim, +}; +#[cfg(feature = "provider-store")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "provider-store")))] +pub use operations::RedbOperationalEffectStore; +pub use operations::{ + MemoryOperationalEffectStore, OperationalAuditRecord, OperationalCheckpointAuthorizer, + OperationalCheckpointBuild, OperationalCheckpointCommit, OperationalEffectJournal, + OperationalEffectPhase, OperationalEffectRecord, OperationalEffectStore, + OperationalGroupKeyRotator, OperationalMetricsSnapshot, OperationalPeerNotifier, + OperationalProviderReceipt, build_authorize_and_commit_checkpoint, complete_ready_effect, + notify_and_complete_effect, publish_and_journal_checkpoint, rotate_and_journal_group_keys, +}; +#[cfg(feature = "fs-store")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "fs-store")))] +pub use pairing::RedbPairingNonceStore; +pub use pairing::{ + AuthenticatedTransportBinding, Cancelled, ConfirmationParticipant, Confirmed, Connected, + ConnectionEphemeralSecret, Consumed, Expired, Issued, MAX_PAIRING_ENDPOINT_HINT_BYTES, + MemoryPairingNonceStore, NonceConsumeResult, PairingAdmission, PairingCeremony, + PairingChallenge, PairingConfirmation, PairingConfirmationContext, PairingConfirmationOutcome, + PairingConsumeError, PairingConsumeOutcome, PairingNonce, PairingNonceKey, PairingNonceStore, + PairingPossessionProof, PairingProofId, PairingSessionId, PairingTicket, PairingTicketId, + PairingTicketRequest, PairingTicketSecrets, PairingTranscript, PairingTranscriptId, Proven, + ShortAuthString, +}; +pub use policy::{ + ControlPolicy, ControllerClassSet, ControllerIdSet, ControllerSelector, ControllerThreshold, + FreshnessRequirement, GuardianSetRoot, GuardianThreshold, PolicyRule, ProviderFreshness, + ProviderMode, ProviderPolicy, ProviderRotationRule, RecoveryAuthority, RecoveryPolicy, + RecoveryPolicyVersion, ReplicatedProviderPolicy, +}; +pub use presence::{ + DevicePresenceChallenge, PresenceProof, PresenceProofId, PresenceSessionId, + PresenceVerifierChallenge, verify_presence_proof, +}; +pub use privacy::{ + ApplicationBackupData, ApplicationDataRestoration, BackupAuthorityBundle, BackupEnvelope, + BackupPassphrase, BackupRestoration, BlindedCommitment, BlindingSecret, + CanonicalSigningRequest, CredentialClaim, CredentialVerificationContext, + HardwareApprovalRequest, HardwareController, LookupHandleSecret, OfflineSigner, + PairwiseIdentifier, PairwiseMasterSecret, PortableCredentialBody, PrivateArtifactContext, + PrivateCheckpointLookupHandle, PrivateLabel, PrivateMetadata, PrivateMetadataEnvelope, + PrivateMetadataKey, RelyingPartyContext, RestoredAccountAuthority, SignedPortableCredential, + SigningPurpose, VerifiedPortableCredential, verify_portable_credential, +}; +pub use proposal::{DeviceAuthorizationProposal, DeviceAuthorizationProposalId}; +#[cfg(feature = "provider-store")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "provider-store")))] +pub use provider::RedbProviderStore; +pub use provider::{ + AddressedProviderGeneration, MAX_PROVIDER_EXPORT_CHUNK_BYTES, MAX_PROVIDER_EXPORT_CHUNK_ITEMS, + MAX_PROVIDER_EXPORT_ITEM_BYTES, MAX_PROVIDER_PORTABLE_AUDIT_BYTES, + MAX_PROVIDER_PORTABLE_GENERATION_BYTES, MemoryProviderStore, OpaqueProviderAnchorCommitment, + ProviderAccountHistoryPage, ProviderAccountHistoryRecord, ProviderAdmissionControl, + ProviderAdmissionRequest, ProviderAnchor, ProviderAnchorEvidence, ProviderAnchorStatus, + ProviderAppendPermit, ProviderAuditExportAssembler, ProviderAuditExportChunk, + ProviderAuditExportManifest, ProviderCompactionAuthorization, ProviderCompactionManifest, + ProviderExportComponent, ProviderExportComponentDescriptor, ProviderGenerationExport, + ProviderGenerationExportAssembler, ProviderGenerationExportChunk, + ProviderGenerationExportManifest, ProviderGenerationRegistry, ProviderGenerationRoute, + ProviderGenerationSnapshot, ProviderRecoveryExport, ProviderRecoveryExportManifest, + ProviderRetainedCheckpointEvidence, ProviderRetainedRange, ProviderRetentionClass, + ProviderRetentionInventory, ProviderRetentionItem, authorize_provider_append, + derive_provider_retention_inventory, verify_provider_compaction, +}; +pub use publication::{ + ProviderCheckpointLineagePage, ProviderPublicationOutcome, PublicationBatch, PublicationStage, + PublicationTracker, PublishedCheckpoint, TransparencyClient, publish_checkpoint_concurrently, +}; +pub use recovery::{ + BeginRecovery, CancelRecovery, FinalizeRecovery, ForkCommonAncestor, ForkDescriptor, + GUARDIAN_GRANT_LEAF_TYPE_TAG, GuardianApprovalBody, GuardianApprovalDecision, + GuardianApprovalSet, GuardianAuthorityContext, GuardianGrant, GuardianGrantOpening, + RecoveryAuthorityPlan, RecoveryDelayAnchor, RecoveryProposal, RecoveryThresholdEvidence, + ResolveFork, SignedGuardianApproval, VerifiedGuardianAuthority, VetoRecovery, + verify_guardian_authority, +}; +pub use schema::{ + AccountId, AdmissionEvidenceId, AlgorithmPublicKey, AlgorithmSignature, ApplicationEventId, + ApplicationId, CapabilityGrantId, CheckpointId, ControlPolicyId, ControllerApprovalId, + ControllerId, ControllerKeyId, ControllerWeight, CryptoMigrationId, CryptoStateId, + CryptoSuiteId, DelegationDepth, DelegationId, DeviceId, EventAuthorizationId, EventId, + EventIntentApprovalId, ForkId, GenesisAnchor, GroupId, GroupKeyEpoch, GroupKeyWrapId, + GuardianGrantId, ProposalId, ProtocolMajor, ProviderId, ProviderKeyVersion, ProviderLogId, + ProviderPolicyId, ProviderPolicyVersion, ProviderQuorum, RecoveryId, RecoveryPolicyId, + RequiredWeight, RevocationReasonCode, +}; +pub use social::{ + SignedSocialAttestation, SocialAttestationBody, SocialAttestationVerificationContext, + SocialTransitivityPolicy, SocialTrustHint, VerifiedSocialAttestation, evaluate_social_trust, + verify_social_attestation, +}; +pub use state::{ + AccountRevision, AccountState, ApplyDisposition, ApplyOutcome, ProjectedController, + ProjectedDevice, ProjectedDeviceLifecycle, ProjectionEffect, ProjectionLifecycle, +}; +#[cfg(feature = "fs-store")] +#[cfg_attr(krikos_docsrs, doc(cfg(feature = "fs-store")))] +pub use store::RedbAccountStore; +pub use store::{ + AccountSnapshot, AccountStore, BatchCommitReceipt, CheckpointCommitReceipt, + CheckpointJournalPage, CheckpointJournalRecord, ClaimEffects, CommitReceipt, EffectFailure, + EffectId, EffectRecord, EffectStatus, EventHistoryCursor, EventHistoryPage, EventHistoryRecord, + ForkEvidenceRecord, LeaseId, MemoryAccountStore, StoreFuture, StoredGroupKeyRotation, +}; +pub use sync::{ + CursorKey, SyncCursor, SyncFrame, SyncRequest, SyncResponse, SyncSessionBudget, + reconcile_sync_frame, serve_sync_request, +}; +pub use transparency::{ + MemoryTransparencyLog, ProviderHeadAuditDisposition, ProviderHeadAuditor, ProviderHeadSigner, + ProviderHistoryPage, ProviderHistoryRecord, ProviderLogAdmission, + verify_event_intent_admission, verify_guardian_recovery_intent_admission, +}; +pub use types::{ + AeadAlgorithm, AgreementAlgorithm, AgreementPublicKey, Digest, DurationMillis, Epoch, + HashAlgorithm, KdfAlgorithm, OperationKind, ProtocolSignature, ProtocolVersion, + RESERVED_PUBLISH_CHECKPOINT_CODE, Sequence, SignatureAlgorithm, SigningPublicKey, Timestamp, +}; diff --git a/protocols/krikos-identity/src/limits.rs b/protocols/krikos-identity/src/limits.rs new file mode 100644 index 00000000000..450682c68db --- /dev/null +++ b/protocols/krikos-identity/src/limits.rs @@ -0,0 +1,187 @@ +//! Named bounds for every protocol-controlled allocation and collection. + +use std::time::Duration; + +/// Largest accepted canonical protocol object. +pub const MAX_ENCODED_OBJECT_BYTES: usize = 1024 * 1024; +/// Largest accepted account-control event. +pub const MAX_ACCOUNT_EVENT_BYTES: usize = 256 * 1024; +/// Largest accepted device-pairing ticket. +pub const MAX_PAIRING_TICKET_BYTES: usize = 16 * 1024; +/// Largest accepted synchronization frame. +pub const MAX_SYNC_FRAME_BYTES: usize = 4 * 1024 * 1024; +/// Maximum account events in one synchronization batch. +pub const MAX_EVENTS_PER_SYNC_BATCH: usize = 256; +/// Maximum controllers retained by one account. +pub const MAX_CONTROLLERS: usize = 64; +/// Maximum devices retained by one account, including tombstones. +pub const MAX_DEVICES: usize = 1024; +/// Maximum rules in one control policy. +pub const MAX_POLICY_RULES: usize = 64; +/// Maximum signatures accepted as authorization for one object. +pub const MAX_AUTHORIZATION_SIGNATURES: usize = 64; +/// Maximum simultaneously accepted controller-signature suites during migration. +pub const MAX_ACTIVE_CRYPTO_SUITES: usize = 2; +/// Maximum encoded public signing key in an explicit crypto migration. +pub const MAX_ALGORITHM_PUBLIC_KEY_BYTES: usize = 4 * 1024; +/// Maximum encoded signature in an explicit crypto migration. +pub const MAX_ALGORITHM_SIGNATURE_BYTES: usize = 8 * 1024; +/// Maximum capabilities granted to one device. +pub const MAX_CAPABILITIES_PER_DEVICE: usize = 128; +/// Maximum constraints attached to one capability. +pub const MAX_CONSTRAINTS_PER_CAPABILITY: usize = 32; +/// Maximum capability-delegation chain depth. +pub const MAX_DELEGATION_DEPTH: usize = 8; +/// Maximum transparency providers configured for one account. +pub const MAX_TRANSPARENCY_PROVIDERS: usize = 16; +/// Maximum recovery guardians configured for one account. +pub const MAX_RECOVERY_GUARDIANS: usize = 16; +/// Maximum hashes accepted in one Merkle proof path. +pub const MAX_MERKLE_PROOF_HASHES: usize = 64; +/// Maximum leaves materialized in one account-state sorted Merkle set. +pub const MAX_MERKLE_SET_LEAVES: usize = 2_048; +/// Maximum leaves held by one bounded in-memory provider-log generation. +/// +/// Durable providers rotate to a new versioned log generation before reaching this bound. +pub const MAX_MERKLE_LOG_LEAVES: usize = 1_048_576; +/// Maximum extension fields on one extensible v1 object. +pub const MAX_EXTENSIONS: usize = 32; +/// Maximum value bytes in one extension field. +pub const MAX_EXTENSION_VALUE_BYTES: usize = 16 * 1024; +/// Maximum combined value bytes across an object's extension fields. +pub const MAX_TOTAL_EXTENSION_BYTES: usize = 64 * 1024; +/// Maximum retained branch heads for one account fork. +pub const MAX_FORK_HEADS: usize = 16; +/// Maximum encoded fork evidence retained in one account revision. +pub const MAX_FORK_EVIDENCE_BYTES: usize = 4 * 1024 * 1024; +/// Maximum UTF-8 bytes in a capability namespace or action. +pub const MAX_CAPABILITY_NAME_BYTES: usize = 128; +/// Maximum encoded bytes in a capability resource selector. +pub const MAX_RESOURCE_SELECTOR_BYTES: usize = 1024; +/// Maximum encrypted private-metadata envelope. +pub const MAX_PRIVATE_METADATA_BYTES: usize = 256 * 1024; +/// Maximum encrypted account backup envelope. +pub const MAX_PRIVATE_BACKUP_BYTES: usize = MAX_ENCODED_OBJECT_BYTES; +/// Maximum application-private bytes carried by one account backup. +pub const MAX_APPLICATION_BACKUP_DATA_BYTES: usize = 256 * 1024; +/// Maximum private relationship-label bytes accepted before blinding. +pub const MAX_PRIVATE_LABEL_BYTES: usize = 128; +/// Maximum normalized relying-party context bytes. +pub const MAX_RELYING_PARTY_CONTEXT_BYTES: usize = 253; +/// Maximum selectively disclosed claims in one portable credential. +pub const MAX_CREDENTIAL_CLAIMS: usize = 64; +/// Maximum portable-credential claim-name bytes. +pub const MAX_CREDENTIAL_CLAIM_NAME_BYTES: usize = 64; +/// Maximum disclosed value bytes in one portable-credential claim. +pub const MAX_CREDENTIAL_CLAIM_VALUE_BYTES: usize = 16 * 1024; +/// Maximum complete canonical portable credential export. +pub const MAX_PORTABLE_CREDENTIAL_BYTES: usize = 512 * 1024; +/// Maximum exact canonical bytes sent to an offline or hardware signer. +pub const MAX_OFFLINE_SIGNING_REQUEST_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; +/// Maximum complete canonical signed application event. +pub const MAX_APPLICATION_EVENT_BYTES: usize = 1024 * 1024; +/// Maximum application payload, reserving bounded envelope/signature overhead. +pub const MAX_APPLICATION_PAYLOAD_BYTES: usize = MAX_APPLICATION_EVENT_BYTES - 4 * 1024; +/// Maximum encoded wrapped group key for one recipient. +pub const MAX_KEY_WRAP_BYTES: usize = 4 * 1024; +/// Maximum pending account proposals retained locally. +pub const MAX_PENDING_PROPOSALS: usize = 128; +/// Maximum live, unconsumed pairing tickets retained locally. +pub const MAX_LIVE_PAIRING_TICKETS: usize = 64; +/// Maximum durable pairing nonce tombstones retained by one nonce store instance. +/// +/// Reaching this bound is an availability failure; an implementation must never evict a +/// tombstone in a way that permits a previously consumed ticket to revive. +pub const MAX_PAIRING_NONCE_TOMBSTONES: usize = 65_536; +/// Maximum concurrent recovery attempts for one account. +pub const MAX_CONCURRENT_RECOVERY_ATTEMPTS: usize = 8; +/// Maximum complete canonical social attestation. +pub const MAX_SOCIAL_ATTESTATION_BYTES: usize = 64 * 1024; +/// Maximum explicitly enabled social-attestation chain depth. +pub const MAX_SOCIAL_TRANSITIVITY_DEPTH: usize = 8; +/// Maximum lowercase ASCII DNS-style name bytes. +pub const MAX_NORMALIZED_NAME_BYTES: usize = 253; +/// Maximum complete canonical signed name claim. +pub const MAX_NAME_CLAIM_BYTES: usize = 64 * 1024; +/// Maximum name claims returned by one resolver query. +pub const MAX_NAME_CLAIMS: usize = 64; +/// Maximum history events processed in one page or projection call. +pub const MAX_HISTORY_PAGE_EVENTS: usize = 256; +/// Maximum encoded history bytes returned in one page. +pub const MAX_HISTORY_PAGE_BYTES: usize = 4 * 1024 * 1024; +/// Maximum total bytes exchanged in one synchronization session. +pub const MAX_SYNC_SESSION_BYTES: usize = 16 * 1024 * 1024; +/// Maximum bytes returned by one provider account-history request. +pub const MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +/// Maximum concurrently running identity streams or child tasks. +pub const MAX_CONCURRENT_IDENTITY_TASKS: usize = 64; +/// Capacity of identity actor and effect queues. +pub const IDENTITY_QUEUE_CAPACITY: usize = 256; +/// Maximum attempts for a bounded retry policy. +pub const MAX_RETRIES: u8 = 8; +/// Maximum validity of a device-pairing ticket. +pub const MAX_PAIRING_LIFETIME: Duration = Duration::from_secs(10 * 60); +/// Maximum validity of a presence proof. +pub const MAX_PRESENCE_LIFETIME: Duration = Duration::from_secs(5 * 60); +/// Maximum accepted future clock skew for time-bound proofs. +pub const MAX_FUTURE_CLOCK_SKEW: Duration = Duration::from_secs(2 * 60); +/// Maximum graceful shutdown duration for identity tasks. +pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); + +const _: () = { + assert!(MAX_ENCODED_OBJECT_BYTES > 0); + assert!(MAX_ACCOUNT_EVENT_BYTES <= MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_PAIRING_TICKET_BYTES <= MAX_ACCOUNT_EVENT_BYTES); + assert!(MAX_SYNC_FRAME_BYTES >= MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_EVENTS_PER_SYNC_BATCH > 0); + assert!(MAX_CONTROLLERS > 0); + assert!(MAX_DEVICES >= MAX_CONTROLLERS); + assert!(MAX_POLICY_RULES > 0); + assert!(MAX_AUTHORIZATION_SIGNATURES >= MAX_CONTROLLERS); + assert!(MAX_ACTIVE_CRYPTO_SUITES == 2); + assert!(MAX_ALGORITHM_PUBLIC_KEY_BYTES <= MAX_ACCOUNT_EVENT_BYTES); + assert!(MAX_ALGORITHM_SIGNATURE_BYTES <= MAX_ACCOUNT_EVENT_BYTES); + assert!(MAX_CAPABILITIES_PER_DEVICE > 0); + assert!(MAX_CONSTRAINTS_PER_CAPABILITY > 0); + assert!(MAX_DELEGATION_DEPTH > 0); + assert!(MAX_TRANSPARENCY_PROVIDERS > 0); + assert!(MAX_RECOVERY_GUARDIANS > 0); + assert!(MAX_MERKLE_PROOF_HASHES == u64::BITS as usize); + assert!(MAX_MERKLE_SET_LEAVES >= MAX_DEVICES + MAX_CONTROLLERS); + assert!(MAX_MERKLE_LOG_LEAVES >= MAX_MERKLE_SET_LEAVES); + assert!(MAX_EXTENSIONS > 0); + assert!(MAX_EXTENSION_VALUE_BYTES <= MAX_TOTAL_EXTENSION_BYTES); + assert!(MAX_TOTAL_EXTENSION_BYTES <= MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_FORK_HEADS > 1); + assert!(MAX_FORK_EVIDENCE_BYTES >= MAX_ACCOUNT_EVENT_BYTES); + assert!(MAX_CAPABILITY_NAME_BYTES > 0); + assert!(MAX_RESOURCE_SELECTOR_BYTES > 0); + assert!(MAX_PRIVATE_METADATA_BYTES <= MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_PRIVATE_BACKUP_BYTES == MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_APPLICATION_BACKUP_DATA_BYTES < MAX_PRIVATE_BACKUP_BYTES); + assert!(MAX_PRIVATE_LABEL_BYTES > 0); + assert!(MAX_RELYING_PARTY_CONTEXT_BYTES <= 253); + assert!(MAX_CREDENTIAL_CLAIMS > 0); + assert!(MAX_CREDENTIAL_CLAIM_NAME_BYTES <= MAX_PRIVATE_LABEL_BYTES); + assert!(MAX_CREDENTIAL_CLAIM_VALUE_BYTES <= MAX_PRIVATE_METADATA_BYTES); + assert!(MAX_PORTABLE_CREDENTIAL_BYTES < MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_OFFLINE_SIGNING_REQUEST_BYTES <= MAX_ACCOUNT_EVENT_BYTES); + assert!(MAX_APPLICATION_EVENT_BYTES == MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_APPLICATION_PAYLOAD_BYTES < MAX_APPLICATION_EVENT_BYTES); + assert!(MAX_KEY_WRAP_BYTES <= MAX_PAIRING_TICKET_BYTES); + assert!(MAX_PENDING_PROPOSALS > 0); + assert!(MAX_LIVE_PAIRING_TICKETS > 0); + assert!(MAX_CONCURRENT_RECOVERY_ATTEMPTS > 0); + assert!(MAX_SOCIAL_ATTESTATION_BYTES <= MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_SOCIAL_TRANSITIVITY_DEPTH > 0); + assert!(MAX_NORMALIZED_NAME_BYTES <= 253); + assert!(MAX_NAME_CLAIM_BYTES <= MAX_ENCODED_OBJECT_BYTES); + assert!(MAX_NAME_CLAIMS > 0); + assert!(MAX_HISTORY_PAGE_EVENTS == MAX_EVENTS_PER_SYNC_BATCH); + assert!(MAX_HISTORY_PAGE_BYTES == MAX_SYNC_FRAME_BYTES); + assert!(MAX_SYNC_SESSION_BYTES >= MAX_SYNC_FRAME_BYTES); + assert!(MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES <= MAX_SYNC_SESSION_BYTES); + assert!(MAX_CONCURRENT_IDENTITY_TASKS > 0); + assert!(IDENTITY_QUEUE_CAPACITY >= MAX_CONCURRENT_IDENTITY_TASKS); + assert!(MAX_RETRIES > 0); +}; diff --git a/protocols/krikos-identity/src/merkle.rs b/protocols/krikos-identity/src/merkle.rs new file mode 100644 index 00000000000..42c74825d37 --- /dev/null +++ b/protocols/krikos-identity/src/merkle.rs @@ -0,0 +1,973 @@ +//! Deterministic Merkle trees and sorted-set proofs. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + Digest, IdentityError, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_MERKLE_LOG_LEAVES, MAX_MERKLE_PROOF_HASHES, MAX_MERKLE_SET_LEAVES}, + schema::BoundedVec, + types::{HashDomain, hash_bytes}, +}; + +/// Canonical sort key for one leaf in a typed Merkle set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct MerkleSetKey { + type_tag: u16, + id: Digest, +} + +impl MerkleSetKey { + /// Construct a key from a nonzero application-defined type tag and typed identifier digest. + pub const fn new(type_tag: u16, id: Digest) -> Result { + if type_tag == 0 { + return Err(IdentityError::ZeroValue { + resource: "Merkle set leaf type tag", + }); + } + Ok(Self { type_tag, id }) + } + + /// Stable application-defined leaf type tag. + pub const fn type_tag(self) -> u16 { + self.type_tag + } + + /// Identifier that orders this leaf within its type. + pub const fn id(self) -> Digest { + self.id + } +} + +impl<'de> Deserialize<'de> for MerkleSetKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + type_tag: u16, + id: Digest, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.type_tag, wire.id).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for MerkleSetKey { + const RESOURCE: &'static str = "Merkle set key bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Canonical `(type_tag, id, value_hash)` leaf committed by a sorted Merkle set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MerkleSetLeaf { + key: MerkleSetKey, + value_hash: Digest, +} + +impl MerkleSetLeaf { + /// Construct a leaf from its unique sort key and the digest of its typed value. + pub const fn new(key: MerkleSetKey, value_hash: Digest) -> Self { + Self { key, value_hash } + } + + /// Canonical leaf sort key. + pub const fn key(&self) -> MerkleSetKey { + self.key + } + + /// Digest of the leaf's typed value. + pub const fn value_hash(&self) -> Digest { + self.value_hash + } + + fn hash(&self) -> Result { + let payload = encode_wire(&(self.key.type_tag(), self.key.id(), self.value_hash))?; + Ok(hash_bytes(HashDomain::MerkleLeaf, &payload)) + } +} + +impl CanonicalCodec for MerkleSetLeaf { + const RESOURCE: &'static str = "Merkle set leaf bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Bounded append-order Merkle tree for one versioned provider-log generation. +/// +/// Unlike [`MerkleSet`], this structure preserves insertion order. The supplied leaf hashes +/// must already use their protocol-owned leaf domain; only interior nodes and the empty root are +/// derived here. Durable providers rotate `ProviderLogId` before this in-memory generation bound. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AppendOnlyMerkleLog { + leaf_hashes: Vec, +} + +impl AppendOnlyMerkleLog { + /// Construct an empty append-only tree. + pub const fn new() -> Self { + Self { + leaf_hashes: Vec::new(), + } + } + + /// Restore one complete bounded generation in its exact append order. + pub fn from_leaf_hashes(leaf_hashes: Vec) -> Result { + if leaf_hashes.len() > MAX_MERKLE_LOG_LEAVES { + return Err(IdentityError::limit( + "provider Merkle log leaves", + leaf_hashes.len(), + MAX_MERKLE_LOG_LEAVES, + )); + } + Ok(Self { leaf_hashes }) + } + + /// Number of leaves currently committed by this generation. + pub const fn len(&self) -> usize { + self.leaf_hashes.len() + } + + /// Whether this generation has no leaves. + pub const fn is_empty(&self) -> bool { + self.leaf_hashes.is_empty() + } + + /// Exact append-ordered leaf hashes. + pub fn leaf_hashes(&self) -> &[Digest] { + &self.leaf_hashes + } + + /// Append one protocol-domain-separated leaf hash and return its zero-based index. + pub fn append(&mut self, leaf_hash: Digest) -> Result { + if self.leaf_hashes.len() == MAX_MERKLE_LOG_LEAVES { + return Err(IdentityError::limit( + "provider Merkle log leaves", + self.leaf_hashes.len().saturating_add(1), + MAX_MERKLE_LOG_LEAVES, + )); + } + let index = u64::try_from(self.leaf_hashes.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider Merkle log leaf index", + } + })?; + self.leaf_hashes.push(leaf_hash); + Ok(index) + } + + /// Number of leaves represented on the wire by this generation. + pub fn tree_size(&self) -> Result { + u64::try_from(self.leaf_hashes.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider Merkle log tree size", + }) + } + + /// Root of the exact append prefix currently retained. + pub fn root(&self) -> Result { + tree_hash(&self.leaf_hashes) + } + + /// Build an exact bounded inclusion proof for one append index. + pub fn inclusion_proof(&self, leaf_index: u64) -> Result { + let tree_size = self.tree_size()?; + if leaf_index >= tree_size { + return Err(IdentityError::InvalidProof); + } + let index = usize::try_from(leaf_index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider Merkle log leaf index", + })?; + let mut audit_path = Vec::new(); + append_inclusion_path(&self.leaf_hashes, index, &mut audit_path)?; + MerkleInclusionProof::new(leaf_index, tree_size, audit_path) + } + + /// Prove that `old_size` is an exact append-only prefix of the current generation. + pub fn consistency_proof( + &self, + old_size: u64, + ) -> Result { + let new_size = self.tree_size()?; + if old_size > new_size { + return Err(IdentityError::InvalidProof); + } + if old_size == 0 || old_size == new_size { + return MerkleConsistencyProof::new(old_size, new_size, Vec::new()); + } + let prefix_len = + usize::try_from(old_size).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider Merkle log consistency prefix", + })?; + let mut audit_path = Vec::new(); + append_consistency_path(prefix_len, &self.leaf_hashes, true, &mut audit_path)?; + MerkleConsistencyProof::new(old_size, new_size, audit_path) + } +} + +/// Canonical sorted, duplicate-free collection committed by a binary Merkle root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MerkleSet { + entries: Vec, +} + +impl MerkleSet { + /// Sort and validate a complete set of leaves. + pub fn new(mut entries: Vec) -> Result { + if entries.len() > MAX_MERKLE_SET_LEAVES { + return Err(IdentityError::limit( + "Merkle set leaves", + entries.len(), + MAX_MERKLE_SET_LEAVES, + )); + } + entries.sort_unstable_by_key(MerkleSetLeaf::key); + for pair in entries.windows(2) { + if pair[0].key() == pair[1].key() { + return Err(IdentityError::DuplicateElement { + resource: "Merkle set leaf key", + }); + } + } + Ok(Self { entries }) + } + + /// Canonically ordered leaves. + pub fn entries(&self) -> &[MerkleSetLeaf] { + &self.entries + } + + /// Derive the root using the fixed v1 empty, leaf, and interior-node domains. + pub fn root(&self) -> Result { + let hashes = self.leaf_hashes()?; + tree_hash(&hashes) + } + + /// Build a bounded inclusion proof for an exact set key. + pub fn inclusion_proof( + &self, + key: MerkleSetKey, + ) -> Result { + let index = self + .entries + .binary_search_by_key(&key, MerkleSetLeaf::key) + .map_err(|_| IdentityError::InvalidRelationship { + resource: "Merkle inclusion query is absent", + })?; + self.inclusion_proof_at(index) + } + + /// Build an adjacent-neighbor proof that an exact set key is absent. + pub fn non_membership_proof( + &self, + query: MerkleSetKey, + ) -> Result { + let insertion = match self + .entries + .binary_search_by_key(&query, MerkleSetLeaf::key) + { + Ok(_) => { + return Err(IdentityError::InvalidRelationship { + resource: "Merkle non-membership query is present", + }); + } + Err(index) => index, + }; + + let tree_size = + u64::try_from(self.entries.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "Merkle tree size", + })?; + let predecessor = if insertion == 0 { + None + } else { + Some(MerkleNeighbor::new( + self.entries[insertion - 1], + self.inclusion_proof_at(insertion - 1)?, + )?) + }; + let successor = if insertion == self.entries.len() { + None + } else { + Some(MerkleNeighbor::new( + self.entries[insertion], + self.inclusion_proof_at(insertion)?, + )?) + }; + MerkleNonMembershipProof::new(tree_size, predecessor, successor) + } + + /// Prove that the first `old_size` leaves are an exact prefix of this tree. + pub fn consistency_proof( + &self, + old_size: u64, + ) -> Result { + let new_size = + u64::try_from(self.entries.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "Merkle tree size", + })?; + if old_size > new_size { + return Err(IdentityError::InvalidProof); + } + if old_size == 0 || old_size == new_size { + return MerkleConsistencyProof::new(old_size, new_size, Vec::new()); + } + + let old_size = + usize::try_from(old_size).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "Merkle consistency old size", + })?; + let hashes = self.leaf_hashes()?; + let mut audit_path = Vec::new(); + append_consistency_path(old_size, &hashes, true, &mut audit_path)?; + MerkleConsistencyProof::new( + u64::try_from(old_size).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "Merkle consistency old size", + })?, + new_size, + audit_path, + ) + } + + fn inclusion_proof_at(&self, leaf_index: usize) -> Result { + let hashes = self.leaf_hashes()?; + let mut audit_path = Vec::new(); + append_inclusion_path(&hashes, leaf_index, &mut audit_path)?; + let leaf_index = + u64::try_from(leaf_index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "Merkle leaf index", + })?; + let tree_size = + u64::try_from(hashes.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "Merkle tree size", + })?; + MerkleInclusionProof::new(leaf_index, tree_size, audit_path) + } + + fn leaf_hashes(&self) -> Result, IdentityError> { + self.entries.iter().map(MerkleSetLeaf::hash).collect() + } +} + +/// Bottom-up bounded audit path for one exact leaf index and tree size. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MerkleInclusionProof { + leaf_index: u64, + tree_size: u64, + audit_path: BoundedVec, +} + +impl MerkleInclusionProof { + /// Validate and construct an exact-shape inclusion proof. + pub fn new( + leaf_index: u64, + tree_size: u64, + audit_path: Vec, + ) -> Result { + let audit_path = BoundedVec::new("Merkle audit path", audit_path)?; + if tree_size == 0 || leaf_index >= tree_size { + return Err(IdentityError::InvalidProof); + } + let expected = inclusion_path_length(leaf_index, tree_size)?; + if audit_path.len() != expected { + return Err(IdentityError::InvalidProof); + } + Ok(Self { + leaf_index, + tree_size, + audit_path, + }) + } + + /// Zero-based leaf index committed by the proof. + pub const fn leaf_index(&self) -> u64 { + self.leaf_index + } + + /// Number of leaves in the committed tree. + pub const fn tree_size(&self) -> u64 { + self.tree_size + } + + /// Bottom-up sibling hashes. + pub fn audit_path(&self) -> &[Digest] { + self.audit_path.as_slice() + } + + /// Verify this exact leaf against an expected root. + pub fn verify(&self, leaf: &MerkleSetLeaf, expected_root: Digest) -> Result<(), IdentityError> { + self.verify_leaf_hash(leaf.hash()?, expected_root) + } + + /// Verify a leaf hash produced by another protocol-owned Merkle leaf schema. + /// + /// Callers must domain-separate and canonically encode that leaf before invoking this + /// structural proof verifier. + pub fn verify_leaf_hash( + &self, + leaf_hash: Digest, + expected_root: Digest, + ) -> Result<(), IdentityError> { + let mut path_index = 0_usize; + let actual_root = root_from_inclusion( + leaf_hash, + self.leaf_index, + self.tree_size, + self.audit_path.as_slice(), + &mut path_index, + )?; + if path_index != self.audit_path.len() || actual_root != expected_root { + return Err(IdentityError::InvalidProof); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for MerkleInclusionProof { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + leaf_index: u64, + tree_size: u64, + audit_path: BoundedVec, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.leaf_index, wire.tree_size, wire.audit_path.into_vec()) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for MerkleInclusionProof { + const RESOURCE: &'static str = "Merkle inclusion proof bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Bounded proof that one Merkle tree is an exact append-only prefix of another. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MerkleConsistencyProof { + old_size: u64, + new_size: u64, + audit_path: BoundedVec, +} + +impl MerkleConsistencyProof { + /// Validate and construct an exact-shape append-only consistency proof. + pub fn new( + old_size: u64, + new_size: u64, + audit_path: Vec, + ) -> Result { + let audit_path = BoundedVec::new("Merkle consistency path", audit_path)?; + if old_size > new_size { + return Err(IdentityError::InvalidProof); + } + let expected = if old_size == 0 || old_size == new_size { + 0 + } else { + consistency_path_length(old_size, new_size, true)? + }; + if audit_path.len() != expected { + return Err(IdentityError::InvalidProof); + } + Ok(Self { + old_size, + new_size, + audit_path, + }) + } + + /// Leaf count of the earlier tree. + pub const fn old_size(&self) -> u64 { + self.old_size + } + + /// Leaf count of the later tree. + pub const fn new_size(&self) -> u64 { + self.new_size + } + + /// RFC-6962-shaped sibling hashes under Krikos's BLAKE3 domains. + pub fn audit_path(&self) -> &[Digest] { + self.audit_path.as_slice() + } + + /// Verify that `old_root` is an exact append-only prefix of `new_root`. + pub fn verify(&self, old_root: Digest, new_root: Digest) -> Result<(), IdentityError> { + if self.old_size == 0 { + if old_root != empty_merkle_root() + || !self.audit_path.is_empty() + || (self.new_size == 0 && new_root != empty_merkle_root()) + { + return Err(IdentityError::InvalidProof); + } + return Ok(()); + } + if self.old_size == self.new_size { + return if self.audit_path.is_empty() && old_root == new_root { + Ok(()) + } else { + Err(IdentityError::InvalidProof) + }; + } + + let mut old_cursor = self.old_size - 1; + let mut new_cursor = self.new_size - 1; + while old_cursor & 1 == 1 { + old_cursor >>= 1; + new_cursor >>= 1; + } + + let (mut old_hash, mut new_hash, mut path_index) = if old_cursor == 0 { + (old_root, old_root, 0_usize) + } else { + let seed = self + .audit_path + .as_slice() + .first() + .copied() + .ok_or(IdentityError::InvalidProof)?; + (seed, seed, 1_usize) + }; + + while path_index < self.audit_path.len() { + if new_cursor == 0 { + return Err(IdentityError::InvalidProof); + } + let sibling = self.audit_path.as_slice()[path_index]; + if old_cursor & 1 == 1 || old_cursor == new_cursor { + old_hash = node_hash(sibling, old_hash)?; + new_hash = node_hash(sibling, new_hash)?; + while old_cursor != 0 && old_cursor & 1 == 0 { + old_cursor >>= 1; + new_cursor >>= 1; + } + } else { + new_hash = node_hash(new_hash, sibling)?; + } + old_cursor >>= 1; + new_cursor >>= 1; + path_index = path_index + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "Merkle consistency path index", + })?; + } + + if new_cursor != 0 || old_hash != old_root || new_hash != new_root { + return Err(IdentityError::InvalidProof); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for MerkleConsistencyProof { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + old_size: u64, + new_size: u64, + audit_path: BoundedVec, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.old_size, wire.new_size, wire.audit_path.into_vec()) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for MerkleConsistencyProof { + const RESOURCE: &'static str = "Merkle consistency proof bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// A disclosed adjacent leaf and its inclusion proof. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MerkleNeighbor { + leaf: MerkleSetLeaf, + proof: MerkleInclusionProof, +} + +impl MerkleNeighbor { + /// Pair a disclosed leaf with its structurally validated inclusion proof. + pub const fn new( + leaf: MerkleSetLeaf, + proof: MerkleInclusionProof, + ) -> Result { + Ok(Self { leaf, proof }) + } + + /// Disclosed neighboring leaf. + pub const fn leaf(&self) -> &MerkleSetLeaf { + &self.leaf + } + + /// Inclusion proof for the disclosed leaf. + pub const fn proof(&self) -> &MerkleInclusionProof { + &self.proof + } +} + +impl<'de> Deserialize<'de> for MerkleNeighbor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + leaf: MerkleSetLeaf, + proof: MerkleInclusionProof, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.leaf, wire.proof).map_err(de::Error::custom) + } +} + +/// Adjacent-neighbor proof that a key is absent from a sorted Merkle set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MerkleNonMembershipProof { + tree_size: u64, + predecessor: Option, + successor: Option, +} + +impl MerkleNonMembershipProof { + /// Validate and construct an empty, boundary, or adjacent-neighbor proof. + pub fn new( + tree_size: u64, + predecessor: Option, + successor: Option, + ) -> Result { + validate_neighbor_shape(tree_size, predecessor.as_ref(), successor.as_ref())?; + Ok(Self { + tree_size, + predecessor, + successor, + }) + } + + /// Number of leaves committed by the proof. + pub const fn tree_size(&self) -> u64 { + self.tree_size + } + + /// Immediately preceding leaf, if the query is not below the first leaf. + pub const fn predecessor(&self) -> Option<&MerkleNeighbor> { + self.predecessor.as_ref() + } + + /// Immediately succeeding leaf, if the query is not above the last leaf. + pub const fn successor(&self) -> Option<&MerkleNeighbor> { + self.successor.as_ref() + } + + /// Verify absence of `query` against an expected sorted-set root. + pub fn verify(&self, query: MerkleSetKey, expected_root: Digest) -> Result<(), IdentityError> { + validate_neighbor_shape( + self.tree_size, + self.predecessor.as_ref(), + self.successor.as_ref(), + )?; + if self.tree_size == 0 { + return if expected_root == empty_merkle_root() { + Ok(()) + } else { + Err(IdentityError::InvalidProof) + }; + } + + if let Some(predecessor) = &self.predecessor { + predecessor.proof.verify(&predecessor.leaf, expected_root)?; + if predecessor.leaf.key() >= query { + return Err(IdentityError::InvalidProof); + } + } + if let Some(successor) = &self.successor { + successor.proof.verify(&successor.leaf, expected_root)?; + if successor.leaf.key() <= query { + return Err(IdentityError::InvalidProof); + } + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for MerkleNonMembershipProof { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + tree_size: u64, + predecessor: Option, + successor: Option, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.tree_size, wire.predecessor, wire.successor).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for MerkleNonMembershipProof { + const RESOURCE: &'static str = "Merkle non-membership proof bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Root of the empty v1 Merkle tree under its distinct domain separator. +pub fn empty_merkle_root() -> Digest { + hash_bytes(HashDomain::MerkleEmpty, &[]) +} + +fn validate_neighbor_shape( + tree_size: u64, + predecessor: Option<&MerkleNeighbor>, + successor: Option<&MerkleNeighbor>, +) -> Result<(), IdentityError> { + if tree_size == 0 { + return if predecessor.is_none() && successor.is_none() { + Ok(()) + } else { + Err(IdentityError::InvalidProof) + }; + } + let predecessor_index = predecessor.map(|neighbor| neighbor.proof.leaf_index()); + let successor_index = successor.map(|neighbor| neighbor.proof.leaf_index()); + for neighbor in predecessor.into_iter().chain(successor) { + if neighbor.proof.tree_size() != tree_size { + return Err(IdentityError::InvalidProof); + } + } + match (predecessor, successor, predecessor_index, successor_index) { + (None, None, _, _) => Err(IdentityError::InvalidProof), + (None, Some(_), _, Some(0)) => Ok(()), + (Some(_), None, Some(index), _) if index == tree_size - 1 => Ok(()), + (Some(left), Some(right), Some(left_index), Some(right_index)) => { + if left_index.checked_add(1) != Some(right_index) || left.leaf.key() >= right.leaf.key() + { + return Err(IdentityError::InvalidProof); + } + Ok(()) + } + _ => Err(IdentityError::InvalidProof), + } +} + +fn tree_hash(hashes: &[Digest]) -> Result { + match hashes.len() { + 0 => Ok(empty_merkle_root()), + 1 => Ok(hashes[0]), + len => { + let split = largest_power_of_two_less_than_usize(len); + node_hash(tree_hash(&hashes[..split])?, tree_hash(&hashes[split..])?) + } + } +} + +fn node_hash(left: Digest, right: Digest) -> Result { + let payload = encode_wire(&(left, right))?; + Ok(hash_bytes(HashDomain::MerkleNode, &payload)) +} + +fn append_inclusion_path( + hashes: &[Digest], + leaf_index: usize, + audit_path: &mut Vec, +) -> Result<(), IdentityError> { + if hashes.is_empty() || leaf_index >= hashes.len() { + return Err(IdentityError::InvalidProof); + } + if hashes.len() == 1 { + return Ok(()); + } + let split = largest_power_of_two_less_than_usize(hashes.len()); + if leaf_index < split { + append_inclusion_path(&hashes[..split], leaf_index, audit_path)?; + audit_path.push(tree_hash(&hashes[split..])?); + } else { + append_inclusion_path(&hashes[split..], leaf_index - split, audit_path)?; + audit_path.push(tree_hash(&hashes[..split])?); + } + Ok(()) +} + +fn append_consistency_path( + old_size: usize, + hashes: &[Digest], + complete_subtree: bool, + audit_path: &mut Vec, +) -> Result<(), IdentityError> { + if old_size == 0 || old_size > hashes.len() || hashes.is_empty() { + return Err(IdentityError::InvalidProof); + } + if old_size == hashes.len() { + if !complete_subtree { + audit_path.push(tree_hash(hashes)?); + } + return Ok(()); + } + + let split = largest_power_of_two_less_than_usize(hashes.len()); + if old_size <= split { + append_consistency_path(old_size, &hashes[..split], complete_subtree, audit_path)?; + audit_path.push(tree_hash(&hashes[split..])?); + } else { + append_consistency_path(old_size - split, &hashes[split..], false, audit_path)?; + audit_path.push(tree_hash(&hashes[..split])?); + } + Ok(()) +} + +fn root_from_inclusion( + leaf_hash: Digest, + leaf_index: u64, + tree_size: u64, + audit_path: &[Digest], + path_index: &mut usize, +) -> Result { + if tree_size == 0 || leaf_index >= tree_size { + return Err(IdentityError::InvalidProof); + } + if tree_size == 1 { + return Ok(leaf_hash); + } + let split = largest_power_of_two_less_than_u64(tree_size); + if leaf_index < split { + let left = root_from_inclusion(leaf_hash, leaf_index, split, audit_path, path_index)?; + let right = next_path_hash(audit_path, path_index)?; + node_hash(left, right) + } else { + let right = root_from_inclusion( + leaf_hash, + leaf_index - split, + tree_size - split, + audit_path, + path_index, + )?; + let left = next_path_hash(audit_path, path_index)?; + node_hash(left, right) + } +} + +fn next_path_hash(audit_path: &[Digest], path_index: &mut usize) -> Result { + let hash = audit_path + .get(*path_index) + .copied() + .ok_or(IdentityError::InvalidProof)?; + *path_index = path_index + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "Merkle proof path index", + })?; + Ok(hash) +} + +fn inclusion_path_length(mut leaf_index: u64, mut tree_size: u64) -> Result { + let mut length = 0_usize; + while tree_size > 1 { + let split = largest_power_of_two_less_than_u64(tree_size); + if leaf_index >= split { + leaf_index -= split; + tree_size -= split; + } else { + tree_size = split; + } + length = length + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "Merkle proof length", + })?; + } + if leaf_index != 0 || length > MAX_MERKLE_PROOF_HASHES { + return Err(IdentityError::InvalidProof); + } + Ok(length) +} + +fn consistency_path_length( + old_size: u64, + new_size: u64, + complete_subtree: bool, +) -> Result { + if old_size == 0 || old_size > new_size || new_size == 0 { + return Err(IdentityError::InvalidProof); + } + if old_size == new_size { + return Ok(usize::from(!complete_subtree)); + } + let split = largest_power_of_two_less_than_u64(new_size); + let nested = if old_size <= split { + consistency_path_length(old_size, split, complete_subtree)? + } else { + consistency_path_length(old_size - split, new_size - split, false)? + }; + let length = nested + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "Merkle consistency proof length", + })?; + if length > MAX_MERKLE_PROOF_HASHES { + return Err(IdentityError::InvalidProof); + } + Ok(length) +} + +fn largest_power_of_two_less_than_u64(value: u64) -> u64 { + debug_assert!(value > 1); + 1_u64 << (u64::BITS - (value - 1).leading_zeros() - 1) +} + +fn largest_power_of_two_less_than_usize(value: usize) -> usize { + debug_assert!(value > 1); + 1_usize << (usize::BITS - (value - 1).leading_zeros() - 1) +} + +impl fmt::Display for MerkleSetKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}:{}", self.type_tag, self.id) + } +} diff --git a/protocols/krikos-identity/src/names.rs b/protocols/krikos-identity/src/names.rs new file mode 100644 index 00000000000..54858620a23 --- /dev/null +++ b/protocols/krikos-identity/src/names.rs @@ -0,0 +1,601 @@ +//! Untrusted name-resolution candidates, signed claims, and pure TOFU decisions. + +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + AccountId, AlgorithmSignature, CheckpointId, Extensions, IdentityError, ProtocolVersion, + SigningPublicKey, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_NAME_CLAIM_BYTES, MAX_NAME_CLAIMS, MAX_NORMALIZED_NAME_BYTES}, + schema::BoundedVec, +}; + +const NAME_CLAIM_SIGNING_DOMAIN: &[u8] = b"KRIKOS-ID/name-claim/v1"; + +/// A bounded lowercase ASCII DNS-style name. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct NormalizedName(String); + +impl NormalizedName { + /// Normalize and validate a name without ambiguous Unicode processing. + pub fn try_new(value: &str) -> Result { + if value.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "normalized name", + }); + } + if !value.is_ascii() || value.len() > MAX_NORMALIZED_NAME_BYTES { + return Err(IdentityError::limit( + "normalized name", + value.len(), + MAX_NORMALIZED_NAME_BYTES, + )); + } + let normalized = value.to_ascii_lowercase(); + for label in normalized.split('.') { + if label.is_empty() + || label.len() > 63 + || !label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(IdentityError::InvalidEncoding); + } + } + Ok(Self(normalized)) + } + + /// Canonical lowercase name. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for NormalizedName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::try_new(&String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for NormalizedName { + const RESOURCE: &'static str = "normalized name bytes"; + const MAX_ENCODED_BYTES: usize = MAX_NORMALIZED_NAME_BYTES + 4; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Exact self-signed claim binding a normalized name to account authority facts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NameClaimBody { + protocol_version: ProtocolVersion, + name: NormalizedName, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, +} + +impl NameClaimBody { + /// Construct an exact, optionally expiring name claim. + pub fn try_new( + name: NormalizedName, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, + ) -> Result { + Self::from_parts( + name, + subject_account_id, + subject_checkpoint_id, + subject_signing_key, + issued_at, + expires_at, + extensions, + ) + } + + fn from_parts( + name: NormalizedName, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, + ) -> Result { + if expires_at.is_some_and(|expiry| expiry <= issued_at) { + return Err(IdentityError::InvalidRelationship { + resource: "name claim validity interval", + }); + } + extensions.validate_critical(&[])?; + let body = Self { + protocol_version: ProtocolVersion::V1, + name, + subject_account_id, + subject_checkpoint_id, + subject_signing_key, + issued_at, + expires_at, + extensions, + }; + let encoded_len = encode_wire(&body)?.len(); + if encoded_len > MAX_NAME_CLAIM_BYTES { + return Err(IdentityError::limit( + "name claim body bytes", + encoded_len, + MAX_NAME_CLAIM_BYTES, + )); + } + Ok(body) + } + + /// Domain-separated canonical bytes signed by the subject key. + pub fn signing_bytes(&self) -> Result, IdentityError> { + domain_message(NAME_CLAIM_SIGNING_DOMAIN, &encode_wire(self)?) + } + + /// Claimed canonical name. + pub const fn name(&self) -> &NormalizedName { + &self.name + } + + /// Subject account bound to the name. + pub const fn subject_account_id(&self) -> AccountId { + self.subject_account_id + } + + /// Exact subject checkpoint bound to the name. + pub const fn subject_checkpoint_id(&self) -> CheckpointId { + self.subject_checkpoint_id + } + + /// Exact subject key which self-signs the claim. + pub const fn subject_signing_key(&self) -> SigningPublicKey { + self.subject_signing_key + } + + /// Explicit claim issuance time. + pub const fn issued_at(&self) -> Timestamp { + self.issued_at + } + + /// Optional exclusive claim expiry. + pub const fn expires_at(&self) -> Option { + self.expires_at + } +} + +impl<'de> Deserialize<'de> for NameClaimBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + name: NormalizedName, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::from_parts( + wire.name, + wire.subject_account_id, + wire.subject_checkpoint_id, + wire.subject_signing_key, + wire.issued_at, + wire.expires_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for NameClaimBody { + const RESOURCE: &'static str = "name claim body bytes"; + const MAX_ENCODED_BYTES: usize = MAX_NAME_CLAIM_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// A subject-signed name claim returned as untrusted resolver data. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedNameClaim { + body: NameClaimBody, + subject_signature: AlgorithmSignature, +} + +impl SignedNameClaim { + /// Verify and retain one exact subject signature. + pub fn try_new( + body: NameClaimBody, + subject_signature: AlgorithmSignature, + ) -> Result { + verify_signature( + body.subject_signing_key, + &subject_signature, + &body.signing_bytes()?, + )?; + let claim = Self { + body, + subject_signature, + }; + let encoded_len = encode_wire(&claim)?.len(); + if encoded_len > MAX_NAME_CLAIM_BYTES { + return Err(IdentityError::limit( + "signed name claim bytes", + encoded_len, + MAX_NAME_CLAIM_BYTES, + )); + } + Ok(claim) + } + + /// Exact signed claim body. + pub const fn body(&self) -> &NameClaimBody { + &self.body + } + + /// Typed subject signature. + pub const fn subject_signature(&self) -> &AlgorithmSignature { + &self.subject_signature + } +} + +impl<'de> Deserialize<'de> for SignedNameClaim { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (body, subject_signature) = + <(NameClaimBody, AlgorithmSignature)>::deserialize(deserializer)?; + Self::try_new(body, subject_signature).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for SignedNameClaim { + const RESOURCE: &'static str = "signed name claim bytes"; + const MAX_ENCODED_BYTES: usize = MAX_NAME_CLAIM_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Bounded untrusted candidate set returned by a name resolver. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameCandidateSet { + candidates: BoundedVec, +} + +impl NameCandidateSet { + /// Bound an untrusted resolver result before cryptographic processing. + pub fn try_new(candidates: Vec) -> Result { + Ok(Self { + candidates: BoundedVec::new("name resolver candidates", candidates)?, + }) + } + + /// Untrusted signed candidates awaiting caller-authoritative verification. + pub fn as_slice(&self) -> &[SignedNameClaim] { + self.candidates.as_slice() + } +} + +/// Synchronous untrusted name-resolution boundary. +pub trait NameResolver { + /// Return candidate records only, respecting the supplied protocol maximum. + /// + /// Callers still enforce the bound because an untrusted implementation may ignore it. + fn resolve( + &self, + name: &NormalizedName, + maximum_candidates: usize, + ) -> Result, IdentityError>; +} + +/// Query an untrusted resolver and enforce the protocol candidate bound. +pub fn resolve_name_candidates( + resolver: &R, + name: &NormalizedName, +) -> Result { + NameCandidateSet::try_new(resolver.resolve(name, MAX_NAME_CLAIMS)?) +} + +/// Caller-supplied authoritative account/checkpoint/key facts for one name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameAuthorityContext { + name: NormalizedName, + account_id: AccountId, + checkpoint_id: CheckpointId, + signing_key: SigningPublicKey, + authority_time: Timestamp, +} + +impl NameAuthorityContext { + /// Construct exact caller-authenticated facts without ambient lookup or time. + pub const fn try_new( + name: NormalizedName, + account_id: AccountId, + checkpoint_id: CheckpointId, + signing_key: SigningPublicKey, + authority_time: Timestamp, + ) -> Result { + Ok(Self { + name, + account_id, + checkpoint_id, + signing_key, + authority_time, + }) + } +} + +/// A name candidate verified against caller-authoritative exact facts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedNameClaim { + body: NameClaimBody, +} + +impl VerifiedNameClaim { + /// Canonical verified name. + pub const fn name(&self) -> &NormalizedName { + &self.body.name + } + + /// Verified subject account. + pub const fn account_id(&self) -> AccountId { + self.body.subject_account_id + } + + /// Exact verified subject checkpoint. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.body.subject_checkpoint_id + } + + /// Exact verified subject signing key. + pub const fn signing_key(&self) -> SigningPublicKey { + self.body.subject_signing_key + } +} + +/// Bounded list of candidates which matched caller-authoritative facts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedNameCandidates { + candidates: BoundedVec, +} + +impl VerifiedNameCandidates { + /// Verified candidates; this list grants no account authority. + pub fn as_slice(&self) -> &[VerifiedNameClaim] { + self.candidates.as_slice() + } +} + +/// Verify one candidate against exact caller-authenticated name and authority facts. +pub fn verify_name_claim( + claim: &SignedNameClaim, + context: &NameAuthorityContext, +) -> Result { + let body = claim.body(); + if body.name != context.name + || body.subject_account_id != context.account_id + || body.subject_checkpoint_id != context.checkpoint_id + || body.subject_signing_key != context.signing_key + { + return Err(IdentityError::InvalidRelationship { + resource: "name claim verification context", + }); + } + if context.authority_time < body.issued_at + || body + .expires_at + .is_some_and(|expiry| context.authority_time >= expiry) + { + return Err(IdentityError::StaleEvidence); + } + verify_signature( + body.subject_signing_key, + claim.subject_signature(), + &body.signing_bytes()?, + )?; + Ok(VerifiedNameClaim { body: body.clone() }) +} + +/// Filter an untrusted candidate set through bounded caller-authoritative facts. +pub fn verify_name_candidates( + candidates: &NameCandidateSet, + contexts: &[NameAuthorityContext], +) -> Result { + if contexts.len() > MAX_NAME_CLAIMS { + return Err(IdentityError::limit( + "name authority contexts", + contexts.len(), + MAX_NAME_CLAIMS, + )); + } + let verified = candidates + .as_slice() + .iter() + .filter_map(|candidate| { + contexts + .iter() + .find_map(|context| verify_name_claim(candidate, context).ok()) + }) + .collect(); + Ok(VerifiedNameCandidates { + candidates: BoundedVec::new("verified name candidates", verified)?, + }) +} + +/// Immutable trust-on-first-use observation selected by an application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TofuObservation { + name: NormalizedName, + account_id: AccountId, + checkpoint_id: CheckpointId, + signing_key: SigningPublicKey, +} + +impl TofuObservation { + /// Canonical observed name. + pub const fn name(&self) -> &NormalizedName { + &self.name + } + + /// Observed subject account. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact observed checkpoint. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Exact observed signing key. + pub const fn signing_key(&self) -> SigningPublicKey { + self.signing_key + } +} + +/// Pure TOFU comparison result; evaluating it never updates storage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TofuDecision { + /// No prior observation was supplied. + FirstUse { + /// Current verified observation for an application to accept or reject. + observation: TofuObservation, + }, + /// Account, key, and exact checkpoint match the prior observation. + Unchanged { + /// Current verified observation. + observation: TofuObservation, + }, + /// Account and key match, but the opaque checkpoint identifier changed. + /// + /// `CheckpointId` has no ordering semantics. The application must authenticate checkpoint + /// lineage before accepting `current` as an advancement over `previous`. + CheckpointChanged { + /// Prior application-supplied observation. + previous: TofuObservation, + /// Current verified observation whose lineage is not established by TOFU comparison. + current: TofuObservation, + }, + /// Account or key differs from the prior observation and requires explicit handling. + KeyChanged { + /// Prior application-supplied observation. + previous: TofuObservation, + /// Current verified but not automatically trusted observation. + current: TofuObservation, + }, +} + +/// Compare one verified claim to optional prior TOFU data without mutating a trust store. +pub fn evaluate_name_tofu( + previous: Option<&TofuObservation>, + current: &VerifiedNameClaim, +) -> Result { + let current = TofuObservation { + name: current.name().clone(), + account_id: current.account_id(), + checkpoint_id: current.checkpoint_id(), + signing_key: current.signing_key(), + }; + let Some(previous) = previous else { + return Ok(TofuDecision::FirstUse { + observation: current, + }); + }; + if previous.name != current.name { + return Err(IdentityError::InvalidRelationship { + resource: "TOFU name observation", + }); + } + if previous.account_id != current.account_id || previous.signing_key != current.signing_key { + Ok(TofuDecision::KeyChanged { + previous: previous.clone(), + current, + }) + } else if previous.checkpoint_id == current.checkpoint_id { + Ok(TofuDecision::Unchanged { + observation: current, + }) + } else { + Ok(TofuDecision::CheckpointChanged { + previous: previous.clone(), + current, + }) + } +} + +fn verify_signature( + signing_key: SigningPublicKey, + signature: &AlgorithmSignature, + message: &[u8], +) -> Result<(), IdentityError> { + crate::verifier::verify_algorithm_signature( + signing_key.algorithm().code(), + signing_key.as_bytes(), + signature, + message, + ) +} + +fn domain_message(domain: &[u8], body: &[u8]) -> Result, IdentityError> { + let capacity = domain + .len() + .checked_add(1) + .and_then(|length| length.checked_add(body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "name claim signing bytes", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(domain); + message.push(0); + message.extend_from_slice(body); + Ok(message) +} diff --git a/protocols/krikos-identity/src/net/mod.rs b/protocols/krikos-identity/src/net/mod.rs new file mode 100644 index 00000000000..215a091b16f --- /dev/null +++ b/protocols/krikos-identity/src/net/mod.rs @@ -0,0 +1,416 @@ +//! Optional Tokio/Krikos adapters for bounded identity streams. + +mod protocol; + +use std::{future::Future, sync::Arc}; + +use krikos::endpoint::Connection; +pub use protocol::{ + AuthorizedCheckpointRequest, AuthorizedProposalRequest, AuthorizedSyncRequest, + DenyIdentityProtocolService, EndpointAuthorizationRequest, IdentityProtocolAck, + IdentityProtocolHandler, IdentityProtocolHandlers, IdentityProtocolKind, IdentityProtocolReply, + IdentityProtocolService, IdentityServiceOutcome, ServiceRejectionCode, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + sync::{OwnedSemaphorePermit, Semaphore}, + task::JoinSet, +}; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroizing; + +use crate::{ + AuthenticatedTransportBinding, CanonicalWire, EndpointPublicKey, IdentityError, + PairingSessionId, SigningPublicKey, SyncFrame, SyncSessionBudget, + limits::{ + IDENTITY_QUEUE_CAPACITY, MAX_CONCURRENT_IDENTITY_TASKS, MAX_SYNC_FRAME_BYTES, + SHUTDOWN_TIMEOUT, + }, + pairing::{AuthenticatedTransportAdapter, AuthenticatedTransportFacts, TransportExporterValue}, + transport::PAIRING_ALPN, +}; + +const PAIRING_EXPORTER_LABEL: &[u8] = b"KRIKOS-ID/pairing-exporter/v1"; +const PAIRING_EXPORTER_CONTEXT_DOMAIN: &[u8] = b"KRIKOS-ID/pairing-exporter-context/v1"; +const PAIRING_SESSION_ID_CONTEXT: &str = "KRIKOS-ID/pairing-session-id/v1"; +const NETWORK_FRAME_PREFIX_BYTES: usize = size_of::(); + +/// Local role on one authenticated pairing connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PairingEndpointRole { + /// The local endpoint is the already-authorized controller device. + Controller, + /// The local endpoint is the proposed device being paired. + #[cfg(test)] + ProposedDevice, +} + +struct KrikosPairingAdapter { + facts: AuthenticatedTransportFacts, +} + +impl AuthenticatedTransportAdapter for KrikosPairingAdapter { + fn into_authenticated_transport_facts(self) -> AuthenticatedTransportFacts { + self.facts + } +} + +/// Derive an unforgeable pairing binding from one completed Krikos handshake. +/// +/// Both endpoint identities are derived from the completed connection rather than accepted as +/// freely substitutable hints. +pub(crate) fn pairing_binding_from_connection( + connection: &Connection, + local_role: PairingEndpointRole, +) -> Result { + if connection.alpn() != PAIRING_ALPN { + return Err(IdentityError::InvalidRelationship { + resource: "pairing negotiated ALPN", + }); + } + let local_endpoint_id = connection.local_id(); + let remote_endpoint_id = connection.remote_id(); + if local_endpoint_id == remote_endpoint_id { + return Err(IdentityError::InvalidRelationship { + resource: "pairing authenticated endpoint separation", + }); + } + + let context = pairing_exporter_context(local_endpoint_id, remote_endpoint_id)?; + let mut exporter = Zeroizing::new([0_u8; 32]); + connection + .export_keying_material(&mut exporter[..], PAIRING_EXPORTER_LABEL, &context) + .map_err(|_| IdentityError::InvalidProof)?; + let session_id = PairingSessionId::new(blake3::derive_key( + PAIRING_SESSION_ID_CONTEXT, + &exporter[..], + ))?; + let exporter = TransportExporterValue::new(*exporter)?; + let local_endpoint = endpoint_key_from_krikos(local_endpoint_id)?; + let remote_endpoint = endpoint_key_from_krikos(remote_endpoint_id)?; + let (controller_endpoint, proposed_endpoint) = match local_role { + PairingEndpointRole::Controller => (local_endpoint, remote_endpoint), + #[cfg(test)] + PairingEndpointRole::ProposedDevice => (remote_endpoint, local_endpoint), + }; + AuthenticatedTransportBinding::from_authenticated_adapter(KrikosPairingAdapter { + facts: AuthenticatedTransportFacts { + session_id, + controller_endpoint, + proposed_endpoint, + exporter, + }, + }) +} + +fn pairing_exporter_context( + first: krikos::EndpointId, + second: krikos::EndpointId, +) -> Result, IdentityError> { + let alpn_length = + u16::try_from(PAIRING_ALPN.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "pairing exporter ALPN length", + })?; + let (lower, upper) = if first <= second { + (first, second) + } else { + (second, first) + }; + let capacity = PAIRING_EXPORTER_CONTEXT_DOMAIN + .len() + .checked_add(2) + .and_then(|value| value.checked_add(PAIRING_ALPN.len())) + .and_then(|value| value.checked_add(64)) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "pairing exporter context length", + })?; + let mut context = Vec::with_capacity(capacity); + context.extend_from_slice(PAIRING_EXPORTER_CONTEXT_DOMAIN); + context.extend_from_slice(&alpn_length.to_be_bytes()); + context.extend_from_slice(PAIRING_ALPN); + context.extend_from_slice(lower.as_bytes()); + context.extend_from_slice(upper.as_bytes()); + Ok(context) +} + +/// Convert an authenticated Krikos endpoint ID into the identity endpoint-key role. +pub fn endpoint_key_from_krikos( + endpoint_id: krikos::EndpointId, +) -> Result { + Ok(EndpointPublicKey::new(SigningPublicKey::ed25519( + *endpoint_id.as_bytes(), + )?)) +} + +/// Read one big-endian length-delimited frame after checking all limits before allocation. +pub async fn read_bounded_frame( + reader: &mut R, + session_budget: &mut SyncSessionBudget, + maximum_bytes: usize, +) -> Result, IdentityError> { + if maximum_bytes == 0 || maximum_bytes > MAX_SYNC_FRAME_BYTES { + return Err(IdentityError::limit( + "network frame configured bytes", + maximum_bytes, + MAX_SYNC_FRAME_BYTES, + )); + } + let mut prefix = [0_u8; 4]; + reader + .read_exact(&mut prefix) + .await + .map_err(|_| IdentityError::InvalidEncoding)?; + let declared = u32::from_be_bytes(prefix); + let declared = usize::try_from(declared).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "network frame length", + })?; + if declared > maximum_bytes { + return Err(IdentityError::limit( + "network frame bytes", + declared, + maximum_bytes, + )); + } + session_budget.charge_bytes(framed_network_bytes(declared)?)?; + let mut bytes = vec![0_u8; declared]; + reader + .read_exact(&mut bytes) + .await + .map_err(|_| IdentityError::InvalidEncoding)?; + Ok(bytes) +} + +pub(crate) fn framed_network_bytes(payload_bytes: usize) -> Result { + payload_bytes + .checked_add(NETWORK_FRAME_PREFIX_BYTES) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "network framed bytes", + }) +} + +/// Write one bounded frame with a checked big-endian length prefix. +pub async fn write_bounded_frame( + writer: &mut W, + bytes: &[u8], + maximum_bytes: usize, +) -> Result<(), IdentityError> { + if maximum_bytes == 0 || bytes.len() > maximum_bytes || maximum_bytes > MAX_SYNC_FRAME_BYTES { + return Err(IdentityError::limit( + "network frame bytes", + bytes.len(), + maximum_bytes.min(MAX_SYNC_FRAME_BYTES), + )); + } + let length = u32::try_from(bytes.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "network frame length", + })?; + writer + .write_all(&length.to_be_bytes()) + .await + .map_err(|_| IdentityError::Cancelled)?; + writer + .write_all(bytes) + .await + .map_err(|_| IdentityError::Cancelled)?; + writer.flush().await.map_err(|_| IdentityError::Cancelled) +} + +/// Decode one bounded canonical synchronization frame from a stream. +pub async fn read_sync_frame( + reader: &mut R, + session_budget: &mut SyncSessionBudget, +) -> Result { + let bytes = read_bounded_frame(reader, session_budget, MAX_SYNC_FRAME_BYTES).await?; + SyncFrame::from_canonical_bytes(&bytes) +} + +/// Owned bounded supervisor for identity protocol child tasks. +#[derive(Debug)] +pub struct IdentityTaskSupervisor { + cancellation: CancellationToken, + permits: Arc, + tasks: JoinSet>, +} + +impl IdentityTaskSupervisor { + /// Create an empty supervisor with the frozen queue and concurrency bounds. + pub fn new() -> Self { + Self { + cancellation: CancellationToken::new(), + permits: Arc::new(Semaphore::new(MAX_CONCURRENT_IDENTITY_TASKS)), + tasks: JoinSet::new(), + } + } + + /// Submit owned work, rejecting submissions beyond the bounded pending queue. + pub fn submit(&mut self, task: F) -> Result<(), IdentityError> + where + F: Future> + Send + 'static, + { + if self.cancellation.is_cancelled() { + return Err(IdentityError::Cancelled); + } + if self.tasks.len() >= IDENTITY_QUEUE_CAPACITY { + return Err(IdentityError::ResourceBusy); + } + let cancellation = self.cancellation.clone(); + let permits = self.permits.clone(); + self.tasks.spawn(async move { + let permit = tokio::select! { + () = cancellation.cancelled() => return Err(IdentityError::Cancelled), + permit = permits.acquire_owned() => { + permit.map_err(|_| IdentityError::Cancelled)? + } + }; + run_owned_task(cancellation, permit, task).await + }); + Ok(()) + } + + /// Cancel every child task without detaching it. + pub fn cancel(&self) { + self.cancellation.cancel(); + } + + /// Await the next observable child outcome. + pub async fn join_next(&mut self) -> Option> { + self.tasks + .join_next() + .await + .map(|result| result.unwrap_or(Err(IdentityError::Cancelled))) + } + + /// Cancel and drain all tasks within the frozen ten-second shutdown deadline. + pub async fn shutdown(mut self) -> Result<(), IdentityError> { + self.cancellation.cancel(); + let drain = async { + let mut first_error = None; + while let Some(result) = self.join_next().await { + if let Err(error) = result + && first_error.is_none() + { + first_error = Some(error); + } + } + first_error.map_or(Ok(()), Err) + }; + match tokio::time::timeout(SHUTDOWN_TIMEOUT, drain).await { + Ok(result) => result, + Err(_) => { + self.tasks.abort_all(); + while self.tasks.join_next().await.is_some() {} + Err(IdentityError::Cancelled) + } + } + } +} + +impl Default for IdentityTaskSupervisor { + fn default() -> Self { + Self::new() + } +} + +impl Drop for IdentityTaskSupervisor { + fn drop(&mut self) { + self.cancellation.cancel(); + self.tasks.abort_all(); + } +} + +async fn run_owned_task( + cancellation: CancellationToken, + _permit: OwnedSemaphorePermit, + task: F, +) -> Result<(), IdentityError> +where + F: Future>, +{ + tokio::select! { + () = cancellation.cancelled() => Err(IdentityError::Cancelled), + result = task => result, + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use krikos::{ + Endpoint, RelayMode, + endpoint::presets, + protocol::{AcceptError, ProtocolHandler, Router}, + }; + use tokio::sync::oneshot; + + use super::*; + + #[derive(Debug)] + struct PairingBindingCapture { + binding: Mutex>>, + } + + impl ProtocolHandler for PairingBindingCapture { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let binding = + pairing_binding_from_connection(&connection, PairingEndpointRole::Controller) + .map_err(AcceptError::from_err)?; + let sender = self + .binding + .lock() + .map_err(|_| AcceptError::from_err(std::io::Error::other("capture lock poisoned")))? + .take() + .ok_or_else(|| { + AcceptError::from_err(std::io::Error::other("binding already captured")) + })?; + sender.send(binding).map_err(|_| { + AcceptError::from_err(std::io::Error::other("capture receiver closed")) + })?; + connection.closed().await; + Ok(()) + } + } + + #[tokio::test] + async fn completed_pairing_connection_derives_direction_independent_binding() { + let controller = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let proposed = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let (binding_tx, binding_rx) = oneshot::channel(); + let capture = Arc::new(PairingBindingCapture { + binding: Mutex::new(Some(binding_tx)), + }); + let router = Router::builder(controller.clone()) + .accept(PAIRING_ALPN, capture) + .spawn(); + + let connection = proposed + .connect(controller.addr(), PAIRING_ALPN) + .await + .unwrap(); + let client_binding = + pairing_binding_from_connection(&connection, PairingEndpointRole::ProposedDevice) + .unwrap(); + let server_binding = binding_rx.await.unwrap(); + assert_eq!(client_binding, server_binding); + assert_eq!( + client_binding.controller_endpoint(), + endpoint_key_from_krikos(controller.id()).unwrap() + ); + assert_eq!( + client_binding.proposed_endpoint(), + endpoint_key_from_krikos(proposed.id()).unwrap() + ); + + connection.close(0_u32.into(), b"test complete"); + router.shutdown().await.unwrap(); + proposed.close().await; + } +} diff --git a/protocols/krikos-identity/src/net/protocol.rs b/protocols/krikos-identity/src/net/protocol.rs new file mode 100644 index 00000000000..b0d09815f21 --- /dev/null +++ b/protocols/krikos-identity/src/net/protocol.rs @@ -0,0 +1,1037 @@ +//! Concrete one-request-per-connection handlers for the six identity v1 ALPNs. + +use std::{ + fmt, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use krikos::{ + endpoint::Connection, + protocol::{AcceptError, ProtocolHandler}, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio_util::sync::CancellationToken; + +use super::{ + PairingEndpointRole, framed_network_bytes, pairing_binding_from_connection, read_bounded_frame, + write_bounded_frame, +}; +use crate::{ + AccountId, AccountStore, AuthenticatedTransportBinding, CanonicalWire, CheckpointId, CursorKey, + DeviceAuthorizationProposal, DeviceId, Digest, HashAlgorithm, IdentityError, PairingTicket, + ProtocolVersion, RecoveryProposal, SignedCheckpoint, SignedProviderHead, StoreFuture, + SyncRequest, SyncResponse, SyncSessionBudget, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{ + IDENTITY_QUEUE_CAPACITY, MAX_ACCOUNT_EVENT_BYTES, MAX_CONCURRENT_IDENTITY_TASKS, + MAX_ENCODED_OBJECT_BYTES, MAX_PAIRING_TICKET_BYTES, MAX_SYNC_FRAME_BYTES, + }, + sync::serve_sync_request_with_meter, + transport::{ + AuthorizedEndpointStream, CHECKPOINT_ALPN, PAIRING_ALPN, PROPOSAL_ALPN, RECOVERY_ALPN, + SYNC_ALPN, TRANSPARENCY_GOSSIP_ALPN, VerifiedCheckpointView, authorize_endpoint_stream, + }, +}; + +const REQUEST_COMMITMENT_CONTEXT: &str = "KRIKOS-ID/network-request-commitment/v1"; +const REPLY_ACK_CODE: u16 = 1; +const REPLY_SYNC_CODE: u16 = 2; + +macro_rules! canonical_schema { + ($name:ty, $resource:literal, $maximum:expr) => { + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + const MAX_ENCODED_BYTES: usize = $maximum; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +/// Frozen identity protocol discriminator and its exact negotiated ALPN. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentityProtocolKind { + /// Authenticated device pairing. + Pairing, + /// Frozen-revision account synchronization. + Sync, + /// Device authorization proposal delivery. + Proposal, + /// Signed checkpoint delivery. + Checkpoint, + /// Signed transparency-head gossip. + TransparencyGossip, + /// Guardian recovery proposal delivery. + Recovery, +} + +impl IdentityProtocolKind { + /// Stable v1 network response codepoint. + pub const fn code(self) -> u16 { + match self { + Self::Pairing => 1, + Self::Sync => 2, + Self::Proposal => 3, + Self::Checkpoint => 4, + Self::TransparencyGossip => 5, + Self::Recovery => 6, + } + } + + /// Exact ALPN that must have completed negotiation for this handler. + pub const fn alpn(self) -> &'static [u8] { + match self { + Self::Pairing => PAIRING_ALPN, + Self::Sync => SYNC_ALPN, + Self::Proposal => PROPOSAL_ALPN, + Self::Checkpoint => CHECKPOINT_ALPN, + Self::TransparencyGossip => TRANSPARENCY_GOSSIP_ALPN, + Self::Recovery => RECOVERY_ALPN, + } + } + + fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::Pairing), + 2 => Ok(Self::Sync), + 3 => Ok(Self::Proposal), + 4 => Ok(Self::Checkpoint), + 5 => Ok(Self::TransparencyGossip), + 6 => Ok(Self::Recovery), + _ => Err(IdentityError::UnsupportedCodepoint { + registry: "identity network protocol", + code, + }), + } + } + + const fn maximum_request_bytes(self) -> usize { + match self { + Self::Pairing => MAX_PAIRING_TICKET_BYTES, + Self::Proposal | Self::Recovery => MAX_ACCOUNT_EVENT_BYTES, + Self::Sync => MAX_SYNC_FRAME_BYTES, + Self::Checkpoint | Self::TransparencyGossip => MAX_ENCODED_OBJECT_BYTES, + } + } +} + +/// Exact account-device authorization coordinates carried by device-authorized requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct EndpointAuthorizationRequest { + protocol_version: ProtocolVersion, + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, +} + +impl EndpointAuthorizationRequest { + /// Bind a request to one exact verified checkpoint and active device. + pub const fn new( + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + ) -> Self { + Self { + protocol_version: ProtocolVersion::V1, + account_id, + checkpoint_id, + device_id, + } + } + + /// Requested account. + pub const fn account_id(self) -> AccountId { + self.account_id + } + + /// Exact verified checkpoint used for endpoint authorization. + pub const fn checkpoint_id(self) -> CheckpointId { + self.checkpoint_id + } + + /// Device expected to own the authenticated remote endpoint. + pub const fn device_id(self) -> DeviceId { + self.device_id + } +} + +impl<'de> Deserialize<'de> for EndpointAuthorizationRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(serde::de::Error::custom( + IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + }, + )); + } + Ok(Self::new( + wire.account_id, + wire.checkpoint_id, + wire.device_id, + )) + } +} + +impl CanonicalCodec for EndpointAuthorizationRequest { + const RESOURCE: &'static str = "endpoint authorization request bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ENCODED_OBJECT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + #[derive(Deserialize, Serialize)] + struct Wire { + protocol_version: u16, + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + } + + let wire: Wire = decode_wire(bytes)?; + ProtocolVersion::new(wire.protocol_version)?; + Ok(Self::new( + wire.account_id, + wire.checkpoint_id, + wire.device_id, + )) + } +} + +/// Checkpoint-authorized synchronization request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuthorizedSyncRequest { + authorization: EndpointAuthorizationRequest, + request: SyncRequest, +} + +impl AuthorizedSyncRequest { + /// Construct a request whose account matches the authorization coordinates. + pub fn new( + authorization: EndpointAuthorizationRequest, + request: SyncRequest, + ) -> Result { + if authorization.account_id != request.account_id() { + return Err(IdentityError::AccountMismatch); + } + Ok(Self { + authorization, + request, + }) + } + + /// Unverified authorization coordinates. + pub const fn authorization(&self) -> EndpointAuthorizationRequest { + self.authorization + } + + /// Bounded synchronization request. + pub const fn request(&self) -> &SyncRequest { + &self.request + } +} + +impl<'de> Deserialize<'de> for AuthorizedSyncRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + authorization: EndpointAuthorizationRequest, + request: SyncRequest, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.authorization, wire.request).map_err(serde::de::Error::custom) + } +} + +canonical_schema!( + AuthorizedSyncRequest, + "authorized sync request bytes", + MAX_SYNC_FRAME_BYTES +); + +/// Checkpoint-authorized device proposal request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuthorizedProposalRequest { + authorization: EndpointAuthorizationRequest, + proposal: DeviceAuthorizationProposal, +} + +impl AuthorizedProposalRequest { + /// Construct a request whose account matches the proposal. + pub fn new( + authorization: EndpointAuthorizationRequest, + proposal: DeviceAuthorizationProposal, + ) -> Result { + if authorization.account_id != proposal.account_id() { + return Err(IdentityError::AccountMismatch); + } + Ok(Self { + authorization, + proposal, + }) + } + + /// Unverified authorization coordinates. + pub const fn authorization(&self) -> EndpointAuthorizationRequest { + self.authorization + } + + /// Bounded pairing-derived proposal. + pub const fn proposal(&self) -> &DeviceAuthorizationProposal { + &self.proposal + } +} + +impl<'de> Deserialize<'de> for AuthorizedProposalRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + authorization: EndpointAuthorizationRequest, + proposal: DeviceAuthorizationProposal, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.authorization, wire.proposal).map_err(serde::de::Error::custom) + } +} + +canonical_schema!( + AuthorizedProposalRequest, + "authorized proposal request bytes", + MAX_ACCOUNT_EVENT_BYTES +); + +/// Checkpoint-authorized signed-checkpoint request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuthorizedCheckpointRequest { + authorization: EndpointAuthorizationRequest, + checkpoint: SignedCheckpoint, +} + +impl AuthorizedCheckpointRequest { + /// Construct a request whose account matches the checkpoint body. + pub fn new( + authorization: EndpointAuthorizationRequest, + checkpoint: SignedCheckpoint, + ) -> Result { + if authorization.account_id != checkpoint.body().account_id() { + return Err(IdentityError::AccountMismatch); + } + Ok(Self { + authorization, + checkpoint, + }) + } + + /// Unverified authorization coordinates. + pub const fn authorization(&self) -> EndpointAuthorizationRequest { + self.authorization + } + + /// Bounded signed checkpoint. + pub const fn checkpoint(&self) -> &SignedCheckpoint { + &self.checkpoint + } +} + +impl<'de> Deserialize<'de> for AuthorizedCheckpointRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + authorization: EndpointAuthorizationRequest, + checkpoint: SignedCheckpoint, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.authorization, wire.checkpoint).map_err(serde::de::Error::custom) + } +} + +canonical_schema!( + AuthorizedCheckpointRequest, + "authorized checkpoint request bytes", + MAX_ENCODED_OBJECT_BYTES +); + +/// Validated nonzero application rejection code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServiceRejectionCode(u16); + +impl ServiceRejectionCode { + /// Stable default-deny code used when a service method is not implemented. + pub const UNAVAILABLE: Self = Self(1); + + /// Validate a caller-owned nonzero rejection code. + pub fn new(code: u16) -> Result { + if code == 0 { + return Err(IdentityError::ZeroValue { + resource: "identity service rejection code", + }); + } + Ok(Self(code)) + } + + /// Stable caller-defined code. + pub const fn get(self) -> u16 { + self.0 + } +} + +/// Observable caller-owned decision for one fully decoded request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentityServiceOutcome { + /// The caller accepted the request and permits the handler response. + Accepted, + /// The caller rejected the request under a stable nonzero application code. + Rejected(ServiceRejectionCode), +} + +fn default_rejection<'a>() -> StoreFuture<'a, IdentityServiceOutcome> { + Box::pin(async { + Ok(IdentityServiceOutcome::Rejected( + ServiceRejectionCode::UNAVAILABLE, + )) + }) +} + +/// Caller-owned deterministic service boundary for all six decoded v1 request classes. +/// +/// Every method defaults to deny. Implementations receive authenticated endpoint capabilities, +/// never raw endpoint hints, for protocols that require account-device authority. +pub trait IdentityProtocolService: fmt::Debug + Send + Sync + 'static { + /// Process one pairing ticket bound to the completed transport handshake. + fn pairing( + &self, + _transport: AuthenticatedTransportBinding, + _ticket: PairingTicket, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + default_rejection() + } + + /// Observe one store-derived synchronization result before it is returned. + fn sync( + &self, + _authorized: AuthorizedEndpointStream, + _request: SyncRequest, + _response: SyncResponse, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + default_rejection() + } + + /// Process one checkpoint-authorized device proposal. + fn proposal( + &self, + _authorized: AuthorizedEndpointStream, + _proposal: DeviceAuthorizationProposal, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + default_rejection() + } + + /// Process one checkpoint-authorized signed checkpoint. + fn checkpoint( + &self, + _authorized: AuthorizedEndpointStream, + _checkpoint: SignedCheckpoint, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + default_rejection() + } + + /// Process one signed transparency head from its authenticated transport peer. + fn transparency_gossip( + &self, + _remote_endpoint: crate::EndpointPublicKey, + _head: SignedProviderHead, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + default_rejection() + } + + /// Process one guardian recovery proposal from its authenticated transport peer. + fn recovery( + &self, + _remote_endpoint: crate::EndpointPublicKey, + _proposal: RecoveryProposal, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + default_rejection() + } +} + +/// Default-deny service useful while individual operational integrations are installed. +#[derive(Debug, Default)] +pub struct DenyIdentityProtocolService; + +impl IdentityProtocolService for DenyIdentityProtocolService {} + +/// Canonical acknowledgement of a caller-owned service decision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IdentityProtocolAck { + protocol_version: ProtocolVersion, + protocol_code: u16, + request_commitment: Digest, + decision_code: u16, +} + +impl IdentityProtocolAck { + /// Commit one exact canonical request and caller-owned service outcome. + pub fn for_canonical_request( + kind: IdentityProtocolKind, + canonical_request: &[u8], + outcome: IdentityServiceOutcome, + ) -> Self { + let mut commitment_hasher = blake3::Hasher::new_derive_key(REQUEST_COMMITMENT_CONTEXT); + commitment_hasher.update(&kind.code().to_be_bytes()); + commitment_hasher.update(canonical_request); + let request_commitment = Digest::new( + HashAlgorithm::Blake3_256, + *commitment_hasher.finalize().as_bytes(), + ); + let decision_code = match outcome { + IdentityServiceOutcome::Accepted => 0, + IdentityServiceOutcome::Rejected(code) => code.get(), + }; + Self { + protocol_version: ProtocolVersion::V1, + protocol_code: kind.code(), + request_commitment, + decision_code, + } + } + + /// Protocol whose request was processed. + pub fn protocol(&self) -> Result { + IdentityProtocolKind::from_code(self.protocol_code) + } + + /// Domain-separated commitment to the exact canonical request bytes. + pub const fn request_commitment(&self) -> Digest { + self.request_commitment + } + + /// Whether the caller-owned service accepted the request. + pub const fn accepted(&self) -> bool { + self.decision_code == 0 + } + + /// Caller-owned rejection code, or `None` for acceptance. + pub fn rejection_code(&self) -> Result, IdentityError> { + if self.decision_code == 0 { + Ok(None) + } else { + ServiceRejectionCode::new(self.decision_code).map(Some) + } + } +} + +impl<'de> Deserialize<'de> for IdentityProtocolAck { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + protocol_code: u16, + request_commitment: Digest, + decision_code: u16, + } + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(serde::de::Error::custom( + IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + }, + )); + } + IdentityProtocolKind::from_code(wire.protocol_code).map_err(serde::de::Error::custom)?; + Ok(Self { + protocol_version: wire.protocol_version, + protocol_code: wire.protocol_code, + request_commitment: wire.request_commitment, + decision_code: wire.decision_code, + }) + } +} + +canonical_schema!( + IdentityProtocolAck, + "identity protocol acknowledgement bytes", + MAX_ENCODED_OBJECT_BYTES +); + +/// One bounded response from a concrete identity protocol handler. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IdentityProtocolReply { + protocol_version: ProtocolVersion, + reply_code: u16, + ack: Option, + sync: Option, +} + +impl IdentityProtocolReply { + /// Wrap one canonical service acknowledgement. + pub const fn acknowledgement(ack: IdentityProtocolAck) -> Self { + Self { + protocol_version: ProtocolVersion::V1, + reply_code: REPLY_ACK_CODE, + ack: Some(ack), + sync: None, + } + } + + /// Wrap one accepted synchronization page. + pub const fn synchronization(response: SyncResponse) -> Self { + Self { + protocol_version: ProtocolVersion::V1, + reply_code: REPLY_SYNC_CODE, + ack: None, + sync: Some(response), + } + } + + /// Service acknowledgement, when the reply is not a successful sync data page. + pub const fn as_ack(&self) -> Option<&IdentityProtocolAck> { + self.ack.as_ref() + } + + /// Store-derived synchronization response, when accepted by the service. + pub const fn as_sync(&self) -> Option<&SyncResponse> { + self.sync.as_ref() + } + + fn validate(&self) -> Result<(), IdentityError> { + if self.protocol_version != ProtocolVersion::V1 { + return Err(IdentityError::UnsupportedVersion { + version: self.protocol_version.get(), + }); + } + match (self.reply_code, &self.ack, &self.sync) { + (REPLY_ACK_CODE, Some(_), None) | (REPLY_SYNC_CODE, None, Some(_)) => Ok(()), + (REPLY_ACK_CODE | REPLY_SYNC_CODE, _, _) => Err(IdentityError::InvalidRelationship { + resource: "identity protocol reply payload", + }), + (code, _, _) => Err(IdentityError::UnsupportedCodepoint { + registry: "identity protocol reply", + code, + }), + } + } +} + +impl<'de> Deserialize<'de> for IdentityProtocolReply { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + reply_code: u16, + ack: Option, + sync: Option, + } + let wire = Wire::deserialize(deserializer)?; + let reply = Self { + protocol_version: wire.protocol_version, + reply_code: wire.reply_code, + ack: wire.ack, + sync: wire.sync, + }; + reply.validate().map_err(serde::de::Error::custom)?; + Ok(reply) + } +} + +canonical_schema!( + IdentityProtocolReply, + "identity protocol reply bytes", + MAX_SYNC_FRAME_BYTES +); + +#[derive(Debug)] +struct IdentityHandlerSupervisor { + cancellation: CancellationToken, + permits: Arc, + reservations: AtomicUsize, +} + +impl IdentityHandlerSupervisor { + fn new() -> Arc { + Arc::new(Self { + cancellation: CancellationToken::new(), + permits: Arc::new(Semaphore::new(MAX_CONCURRENT_IDENTITY_TASKS)), + reservations: AtomicUsize::new(0), + }) + } + + fn reserve(self: &Arc) -> Result { + if self.cancellation.is_cancelled() { + return Err(IdentityError::Cancelled); + } + self.reservations + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + if current < IDENTITY_QUEUE_CAPACITY { + current.checked_add(1) + } else { + None + } + }) + .map_err(|_| IdentityError::ResourceBusy)?; + Ok(IdentityHandlerReservation { + supervisor: self.clone(), + permit: None, + }) + } + + fn cancel(&self) { + self.cancellation.cancel(); + } +} + +#[derive(Debug)] +struct IdentityHandlerReservation { + supervisor: Arc, + permit: Option, +} + +impl IdentityHandlerReservation { + async fn activate(mut self) -> Result { + let permit = tokio::select! { + () = self.supervisor.cancellation.cancelled() => { + return Err(IdentityError::Cancelled); + } + permit = self.supervisor.permits.clone().acquire_owned() => { + permit.map_err(|_| IdentityError::Cancelled)? + } + }; + self.permit = Some(permit); + Ok(self) + } +} + +impl Drop for IdentityHandlerReservation { + fn drop(&mut self) { + let previous = self.supervisor.reservations.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous > 0, "identity handler reservation underflow"); + } +} + +/// Factory sharing bounded services, authorization, source history, and cancellation. +#[derive(Clone)] +pub struct IdentityProtocolHandlers { + service: Arc, + checkpoints: Arc, + store: Arc, + cursor_key: Arc, + supervisor: Arc, +} + +impl fmt::Debug for IdentityProtocolHandlers { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityProtocolHandlers") + .finish_non_exhaustive() + } +} + +impl IdentityProtocolHandlers { + /// Construct all six handlers around one shared service, store, and supervisor. + pub fn new( + service: Arc, + checkpoints: Arc, + store: Arc, + cursor_key: CursorKey, + ) -> Self { + Self { + service, + checkpoints, + store, + cursor_key: Arc::new(cursor_key), + supervisor: IdentityHandlerSupervisor::new(), + } + } + + /// Build one concrete handler for an exact ALPN registration. + pub fn handler(&self, kind: IdentityProtocolKind) -> IdentityProtocolHandler { + IdentityProtocolHandler { + kind, + shared: self.clone(), + } + } +} + +/// Bounded supervised handler for one exact identity protocol ALPN. +#[derive(Clone)] +pub struct IdentityProtocolHandler { + kind: IdentityProtocolKind, + shared: IdentityProtocolHandlers, +} + +impl fmt::Debug for IdentityProtocolHandler { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityProtocolHandler") + .field("kind", &self.kind) + .finish_non_exhaustive() + } +} + +impl ProtocolHandler for IdentityProtocolHandler { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + self.accept_owned(connection) + .await + .map_err(AcceptError::from_err) + } + + async fn shutdown(&self) { + self.shared.supervisor.cancel(); + } +} + +impl IdentityProtocolHandler { + async fn accept_owned(&self, connection: Connection) -> Result<(), IdentityError> { + if connection.alpn() != self.kind.alpn() { + return Err(IdentityError::InvalidRelationship { + resource: "identity handler negotiated ALPN", + }); + } + let reservation = self.shared.supervisor.reserve()?; + let _active = reservation.activate().await?; + let outcome = self.process_one_stream(connection.clone()).await; + if outcome.is_err() { + connection.close(0_u32.into(), b"identity request rejected"); + } + outcome + } + + async fn process_one_stream(&self, connection: Connection) -> Result<(), IdentityError> { + let (mut send, mut receive) = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + streams = connection.accept_bi() => streams.map_err(|_| IdentityError::InvalidEncoding)?, + }; + let mut budget = SyncSessionBudget::new(); + let request_bytes = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + bytes = read_bounded_frame( + &mut receive, + &mut budget, + self.kind.maximum_request_bytes(), + ) => bytes?, + }; + let reply = self.dispatch(&connection, &request_bytes).await?; + let reply_bytes = reply.to_canonical_bytes()?; + budget.charge_bytes(framed_network_bytes(reply_bytes.len())?)?; + tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + result = write_bounded_frame(&mut send, &reply_bytes, MAX_SYNC_FRAME_BYTES) => result?, + } + send.finish().map_err(|_| IdentityError::Cancelled)?; + tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => { + return Err(IdentityError::Cancelled); + } + _ = connection.closed() => {} + } + Ok(()) + } + + async fn dispatch( + &self, + connection: &Connection, + request_bytes: &[u8], + ) -> Result { + let remote_endpoint = super::endpoint_key_from_krikos(connection.remote_id())?; + match self.kind { + IdentityProtocolKind::Pairing => { + let ticket = PairingTicket::from_canonical_bytes(request_bytes)?; + let binding = + pairing_binding_from_connection(connection, PairingEndpointRole::Controller)?; + if ticket.proposed_endpoint() != binding.proposed_endpoint() { + return Err(IdentityError::DeviceNotAuthorized); + } + let outcome = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + outcome = self.shared.service.pairing(binding, ticket) => outcome?, + }; + Ok(IdentityProtocolReply::acknowledgement( + IdentityProtocolAck::for_canonical_request(self.kind, request_bytes, outcome), + )) + } + IdentityProtocolKind::Sync => { + let request = AuthorizedSyncRequest::from_canonical_bytes(request_bytes)?; + let authorized = self.authorize(request.authorization, remote_endpoint)?; + let sync_request = request.request; + let response = serve_sync_request_with_meter( + self.shared.store.as_ref(), + self.shared.cursor_key.as_ref(), + &sync_request, + framed_network_bytes(request_bytes.len())?, + sync_reply_framed_bytes, + ) + .await?; + let outcome = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + outcome = self.shared.service.sync(authorized, sync_request, response.clone()) => outcome?, + }; + match outcome { + IdentityServiceOutcome::Accepted => { + Ok(IdentityProtocolReply::synchronization(response)) + } + IdentityServiceOutcome::Rejected(_) => { + Ok(IdentityProtocolReply::acknowledgement( + IdentityProtocolAck::for_canonical_request( + self.kind, + request_bytes, + outcome, + ), + )) + } + } + } + IdentityProtocolKind::Proposal => { + let request = AuthorizedProposalRequest::from_canonical_bytes(request_bytes)?; + let authorized = self.authorize(request.authorization, remote_endpoint)?; + let outcome = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + outcome = self.shared.service.proposal(authorized, request.proposal) => outcome?, + }; + Ok(IdentityProtocolReply::acknowledgement( + IdentityProtocolAck::for_canonical_request(self.kind, request_bytes, outcome), + )) + } + IdentityProtocolKind::Checkpoint => { + let request = AuthorizedCheckpointRequest::from_canonical_bytes(request_bytes)?; + let authorized = self.authorize(request.authorization, remote_endpoint)?; + let outcome = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + outcome = self.shared.service.checkpoint(authorized, request.checkpoint) => outcome?, + }; + Ok(IdentityProtocolReply::acknowledgement( + IdentityProtocolAck::for_canonical_request(self.kind, request_bytes, outcome), + )) + } + IdentityProtocolKind::TransparencyGossip => { + let head = SignedProviderHead::from_canonical_bytes(request_bytes)?; + let outcome = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + outcome = self.shared.service.transparency_gossip(remote_endpoint, head) => outcome?, + }; + Ok(IdentityProtocolReply::acknowledgement( + IdentityProtocolAck::for_canonical_request(self.kind, request_bytes, outcome), + )) + } + IdentityProtocolKind::Recovery => { + let proposal = RecoveryProposal::from_canonical_bytes(request_bytes)?; + let outcome = tokio::select! { + () = self.shared.supervisor.cancellation.cancelled() => return Err(IdentityError::Cancelled), + outcome = self.shared.service.recovery(remote_endpoint, proposal) => outcome?, + }; + Ok(IdentityProtocolReply::acknowledgement( + IdentityProtocolAck::for_canonical_request(self.kind, request_bytes, outcome), + )) + } + } + } + + fn authorize( + &self, + request: EndpointAuthorizationRequest, + remote_endpoint: crate::EndpointPublicKey, + ) -> Result { + authorize_endpoint_stream( + self.shared.checkpoints.as_ref(), + request.account_id, + request.checkpoint_id, + request.device_id, + remote_endpoint, + ) + } +} + +fn sync_reply_framed_bytes(response: &SyncResponse) -> Result { + let reply = IdentityProtocolReply::synchronization(response.clone()); + framed_network_bytes(reply.to_canonical_bytes()?.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_ack_streaming_commitment_matches_protocol_formula() { + let kind = IdentityProtocolKind::Proposal; + let request = b"canonical request"; + let ack = IdentityProtocolAck::for_canonical_request( + kind, + request, + IdentityServiceOutcome::Accepted, + ); + let mut committed = kind.code().to_be_bytes().to_vec(); + committed.extend_from_slice(request); + assert_eq!( + ack.request_commitment(), + Digest::new( + HashAlgorithm::Blake3_256, + blake3::derive_key(REQUEST_COMMITMENT_CONTEXT, &committed), + ) + ); + } + + #[tokio::test] + async fn shared_handler_supervisor_bounds_total_and_active_reservations() { + let supervisor = IdentityHandlerSupervisor::new(); + let mut reservations = Vec::with_capacity(IDENTITY_QUEUE_CAPACITY); + for _ in 0..IDENTITY_QUEUE_CAPACITY { + reservations.push(supervisor.reserve().unwrap()); + } + assert!(matches!( + supervisor.reserve(), + Err(IdentityError::ResourceBusy) + )); + + let mut active = Vec::with_capacity(MAX_CONCURRENT_IDENTITY_TASKS); + for reservation in reservations.drain(..MAX_CONCURRENT_IDENTITY_TASKS) { + active.push(reservation.activate().await.unwrap()); + } + let queued = reservations.pop().unwrap(); + supervisor.cancel(); + assert!(matches!( + queued.activate().await, + Err(IdentityError::Cancelled) + )); + drop(active); + drop(reservations); + assert_eq!(supervisor.reservations.load(Ordering::Acquire), 0); + } +} diff --git a/protocols/krikos-identity/src/operations.rs b/protocols/krikos-identity/src/operations.rs new file mode 100644 index 00000000000..9cb0e0679c0 --- /dev/null +++ b/protocols/krikos-identity/src/operations.rs @@ -0,0 +1,1492 @@ +//! Durable, idempotent operational-effect substep journals. + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex, MutexGuard}, +}; + +use crate::{ + AccountId, AccountSnapshot, AccountStore, AuthorizedEvent, CheckpointBody, + CheckpointCommitReceipt, EffectFailure, EffectId, EffectRecord, EffectStatus, Epoch, + GroupKeyRotation, IdentityError, InclusionReceipt, LeaseId, ProjectionEffect, + ProviderCheckpointBundle, ProviderDescriptor, ProviderId, ProviderLogSubject, ProviderMode, + ProviderPolicy, PublicationBatch, PublicationStage, PublicationTracker, SignedCheckpoint, + StoreFuture, StoredGroupKeyRotation, Timestamp, TransparencyClient, VerifiedCheckpoint, + build_checkpoint_body, + limits::{MAX_RETRIES, MAX_TRANSPARENCY_PROVIDERS}, + merkle::MerkleConsistencyProof, + publish_checkpoint_concurrently, + store::derive_effect_id, + verify_checkpoint, verify_provider_head_progression, +}; + +const MAX_OPERATION_AUDIT_RECORDS: usize = 256; + +#[cfg(feature = "provider-store")] +mod redb; + +#[cfg(feature = "provider-store")] +pub use redb::RedbOperationalEffectStore; + +/// Durable operational phase for one stable Task 6 effect identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OperationalEffectPhase { + /// Task 6 effect is exclusively claimed by one lease. + Claimed, + /// Deterministic checkpoint body has been built for the exact event effect. + CheckpointDraft, + /// Checkpoint authorization has been verified and durably journaled. + CheckpointAuthorized, + /// At least one configured provider receipt has been verified. + Published, + /// The authenticated provider policy's sufficient publication threshold was reached. + Replicated, + /// A sufficient threshold was later re-observed with exact consistency evidence. + Observed, + /// Revision-bound group-key rotation was durably committed by Task 6. + RotationCommitted, + /// Peer notification completed idempotently. + PeersNotified, + /// Retryable failure was scheduled without overstating publication progress. + RetryScheduled, + /// Permanent failure is retained for operator action. + TerminalFailure, + /// Task 6 effect completion and every required operational substep reconciled. + Completed, +} + +fn valid_phase_transition(current: OperationalEffectPhase, next: OperationalEffectPhase) -> bool { + if current == next { + return !matches!( + current, + OperationalEffectPhase::TerminalFailure | OperationalEffectPhase::Completed + ); + } + match current { + OperationalEffectPhase::Claimed => matches!( + next, + OperationalEffectPhase::CheckpointDraft + | OperationalEffectPhase::RotationCommitted + | OperationalEffectPhase::PeersNotified + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure + ), + OperationalEffectPhase::CheckpointDraft => matches!( + next, + OperationalEffectPhase::CheckpointAuthorized + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure + ), + OperationalEffectPhase::CheckpointAuthorized => matches!( + next, + OperationalEffectPhase::Published + | OperationalEffectPhase::Replicated + | OperationalEffectPhase::Observed + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure + | OperationalEffectPhase::Completed + ), + OperationalEffectPhase::Published => matches!( + next, + OperationalEffectPhase::Replicated + | OperationalEffectPhase::Observed + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure + ), + OperationalEffectPhase::Replicated => matches!( + next, + OperationalEffectPhase::Observed + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure + ), + OperationalEffectPhase::Observed + | OperationalEffectPhase::RotationCommitted + | OperationalEffectPhase::PeersNotified => matches!( + next, + OperationalEffectPhase::Completed + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure + ), + OperationalEffectPhase::RetryScheduled => matches!( + next, + OperationalEffectPhase::Claimed + | OperationalEffectPhase::CheckpointDraft + | OperationalEffectPhase::CheckpointAuthorized + | OperationalEffectPhase::Published + | OperationalEffectPhase::Replicated + | OperationalEffectPhase::Observed + | OperationalEffectPhase::RotationCommitted + | OperationalEffectPhase::PeersNotified + | OperationalEffectPhase::TerminalFailure + ), + OperationalEffectPhase::TerminalFailure | OperationalEffectPhase::Completed => false, + } +} + +/// One private-safe terminal or progression audit marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OperationalAuditRecord { + sequence: u64, + phase: OperationalEffectPhase, + recorded_at: Timestamp, +} + +impl OperationalAuditRecord { + /// Monotonic one-based audit sequence within this effect. + pub const fn sequence(self) -> u64 { + self.sequence + } + + /// Durable phase reached at this audit point. + pub const fn phase(self) -> OperationalEffectPhase { + self.phase + } + + /// Explicit caller-supplied audit time. + pub const fn recorded_at(self) -> Timestamp { + self.recorded_at + } +} + +/// Exact publication plus optional later observation retained for one configured provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationalProviderReceipt { + provider: ProviderDescriptor, + publication: InclusionReceipt, + observation: Option<(InclusionReceipt, MerkleConsistencyProof)>, +} + +impl OperationalProviderReceipt { + /// Configured provider descriptor authenticating both receipts. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Stable provider identifier used for canonical sorting and deduplication. + pub fn provider_id(&self) -> Result { + self.provider.id() + } + + /// Original verified checkpoint publication receipt. + pub const fn publication(&self) -> &InclusionReceipt { + &self.publication + } + + /// Later verified observation and its exact append-only proof, when retained. + pub const fn observation(&self) -> Option<(&InclusionReceipt, &MerkleConsistencyProof)> { + match &self.observation { + Some((receipt, proof)) => Some((receipt, proof)), + None => None, + } + } + + fn validate(&self) -> Result<(), IdentityError> { + if self.provider.id()? != self.publication.provider_id() { + return Err(IdentityError::InvalidRelationship { + resource: "operational publication provider", + }); + } + self.publication.verify(&self.provider)?; + if let Some((observation, proof)) = &self.observation { + observation.verify(&self.provider)?; + if observation.entry() != self.publication.entry() + || observation.leaf_index() != self.publication.leaf_index() + { + return Err(IdentityError::InvalidRelationship { + resource: "operational observation publication leaf", + }); + } + verify_provider_head_progression( + &self.provider, + self.publication.signed_head(), + observation.signed_head(), + proof, + )?; + } + Ok(()) + } +} + +/// Complete durable operational journal state for one Task 6 effect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationalEffectRecord { + revision: u64, + effect_id: EffectId, + account_id: AccountId, + effect: ProjectionEffect, + lease_id: LeaseId, + phase: OperationalEffectPhase, + checkpoint_body: Option, + checkpoint: Option, + publication_policy: Option, + provider_receipts: Vec, + rotation_epoch: Option, + attempt_count: u8, + last_failure: Option, + audit: Vec, +} + +impl OperationalEffectRecord { + /// Compare-and-swap revision of this operational substep journal. + pub const fn revision(&self) -> u64 { + self.revision + } + + /// Stable Task 6 effect identifier. + pub const fn effect_id(&self) -> EffectId { + self.effect_id + } + + /// Account owning this effect; never use this field as a telemetry label. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact deterministic Task 6 effect description. + pub const fn effect(&self) -> ProjectionEffect { + self.effect + } + + /// Task 6 lease that owns this execution attempt. + pub const fn lease_id(&self) -> LeaseId { + self.lease_id + } + + /// Current durable operational phase. + pub const fn phase(&self) -> OperationalEffectPhase { + self.phase + } + + /// Deterministic checkpoint body retained before authorization. + pub const fn checkpoint_body(&self) -> Option<&CheckpointBody> { + self.checkpoint_body.as_ref() + } + + /// Verified signed checkpoint retained for publication reconciliation. + pub const fn checkpoint(&self) -> Option<&SignedCheckpoint> { + self.checkpoint.as_ref() + } + + /// Exact checkpoint-bound provider policy used to validate durable publication progress. + pub const fn publication_policy(&self) -> Option<&ProviderPolicy> { + self.publication_policy.as_ref() + } + + /// Canonically sorted distinct-provider receipt journals. + pub fn provider_receipts(&self) -> &[OperationalProviderReceipt] { + &self.provider_receipts + } + + /// Number of Task 6 execution attempts reflected by this record. + pub const fn attempt_count(&self) -> u8 { + self.attempt_count + } + + /// Most recent stable Task 6 failure class. + pub const fn last_failure(&self) -> Option { + self.last_failure + } + + /// Complete bounded phase audit trail. + pub fn audit(&self) -> &[OperationalAuditRecord] { + &self.audit + } + + fn validate(&self) -> Result<(), IdentityError> { + if derive_effect_id(self.account_id, self.effect)? != self.effect_id + || self.attempt_count == 0 + || self.attempt_count > MAX_RETRIES + || self.audit.is_empty() + || self.audit.len() > MAX_OPERATION_AUDIT_RECORDS + || self.revision + != u64::try_from(self.audit.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "operational effect audit revision", + } + })? + { + return Err(IdentityError::StorageCorruption); + } + for (index, audit) in self.audit.iter().enumerate() { + let sequence = u64::try_from(index) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "operational effect audit sequence", + })? + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "operational effect audit sequence", + })?; + if audit.sequence != sequence { + return Err(IdentityError::StorageCorruption); + } + if index == 0 && audit.phase != OperationalEffectPhase::Claimed { + return Err(IdentityError::StorageCorruption); + } + if index > 0 && !valid_phase_transition(self.audit[index - 1].phase, audit.phase) { + return Err(IdentityError::StorageCorruption); + } + } + if self.audit.last().map(|audit| audit.phase) != Some(self.phase) + || self.provider_receipts.len() > MAX_TRANSPARENCY_PROVIDERS + { + return Err(IdentityError::StorageCorruption); + } + let mut previous_provider = None; + for retained in &self.provider_receipts { + retained.validate()?; + let provider_id = retained.provider.id()?; + if previous_provider.is_some_and(|previous| previous >= provider_id) { + return Err(IdentityError::StorageCorruption); + } + previous_provider = Some(provider_id); + } + match self.effect { + ProjectionEffect::PublishAccountEvent { event_id } => { + if self.rotation_epoch.is_some() + || self.checkpoint_body.as_ref().is_some_and(|body| { + body.account_id() != self.account_id || body.event_head() != event_id + }) + || self.checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.body().account_id() != self.account_id + || checkpoint.body().event_head() != event_id + }) + || matches!( + self.phase, + OperationalEffectPhase::RotationCommitted + | OperationalEffectPhase::PeersNotified + ) + { + return Err(IdentityError::StorageCorruption); + } + if let (Some(body), Some(checkpoint)) = (&self.checkpoint_body, &self.checkpoint) + && checkpoint.body() != body + { + return Err(IdentityError::StorageCorruption); + } + let evidence_phase = self.validate_publication_evidence()?; + let sufficient = match self.phase { + OperationalEffectPhase::CheckpointDraft => self.checkpoint_body.is_some(), + OperationalEffectPhase::CheckpointAuthorized => self.checkpoint.is_some(), + OperationalEffectPhase::Published => { + evidence_phase >= PublicationStage::Published + } + OperationalEffectPhase::Replicated => { + evidence_phase >= PublicationStage::Replicated + } + OperationalEffectPhase::Observed => { + evidence_phase >= PublicationStage::Observed + } + OperationalEffectPhase::Completed => { + self.publish_completion_evidence_sufficient()? + } + OperationalEffectPhase::Claimed + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure => true, + OperationalEffectPhase::RotationCommitted + | OperationalEffectPhase::PeersNotified => false, + }; + if !sufficient { + return Err(IdentityError::StorageCorruption); + } + } + ProjectionEffect::RotateGroupKeys { epoch, .. } => { + if self.checkpoint_body.is_some() + || self.checkpoint.is_some() + || self.publication_policy.is_some() + || !self.provider_receipts.is_empty() + || self + .rotation_epoch + .is_some_and(|retained| retained != epoch) + || matches!( + self.phase, + OperationalEffectPhase::CheckpointDraft + | OperationalEffectPhase::CheckpointAuthorized + | OperationalEffectPhase::Published + | OperationalEffectPhase::Replicated + | OperationalEffectPhase::Observed + | OperationalEffectPhase::PeersNotified + ) + || matches!( + self.phase, + OperationalEffectPhase::RotationCommitted + | OperationalEffectPhase::Completed + ) && self.rotation_epoch.is_none() + { + return Err(IdentityError::StorageCorruption); + } + } + ProjectionEffect::NotifyAccountChanged { .. } + | ProjectionEffect::NotifyForkDetected { .. } => { + if self.checkpoint_body.is_some() + || self.checkpoint.is_some() + || self.publication_policy.is_some() + || !self.provider_receipts.is_empty() + || self.rotation_epoch.is_some() + || matches!( + self.phase, + OperationalEffectPhase::CheckpointDraft + | OperationalEffectPhase::CheckpointAuthorized + | OperationalEffectPhase::Published + | OperationalEffectPhase::Replicated + | OperationalEffectPhase::Observed + | OperationalEffectPhase::RotationCommitted + ) + { + return Err(IdentityError::StorageCorruption); + } + } + } + Ok(()) + } + + fn validate_publication_evidence(&self) -> Result { + let Some(policy) = &self.publication_policy else { + if self.provider_receipts.is_empty() && self.checkpoint.is_none() { + return Ok(PublicationStage::Draft); + } + return Err(IdentityError::StorageCorruption); + }; + let checkpoint = self + .checkpoint + .as_ref() + .ok_or(IdentityError::StorageCorruption)?; + if policy.id()? != checkpoint.body().provider_policy_id() { + return Err(IdentityError::StorageCorruption); + } + let ProviderMode::Replicated(replicated) = policy.mode() else { + if self.provider_receipts.is_empty() { + return Ok(PublicationStage::Authorized); + } + return Err(IdentityError::StorageCorruption); + }; + for retained in &self.provider_receipts { + let provider_id = retained.provider.id()?; + let configured = replicated + .providers() + .iter() + .find(|provider| provider.id() == Ok(provider_id)) + .ok_or(IdentityError::StorageCorruption)?; + if configured != &retained.provider { + return Err(IdentityError::StorageCorruption); + } + } + let threshold = usize::from(replicated.sufficient_threshold().get()); + let observed = self + .provider_receipts + .iter() + .filter(|retained| retained.observation.is_some()) + .count(); + Ok(if observed >= threshold { + PublicationStage::Observed + } else if self.provider_receipts.len() >= threshold { + PublicationStage::Replicated + } else if self.provider_receipts.is_empty() { + PublicationStage::Authorized + } else { + PublicationStage::Published + }) + } + + fn publish_completion_evidence_sufficient(&self) -> Result { + let stage = self.validate_publication_evidence()?; + let policy = self + .publication_policy + .as_ref() + .ok_or(IdentityError::StorageCorruption)?; + Ok(match policy.mode() { + ProviderMode::LocalOnly => stage == PublicationStage::Authorized, + ProviderMode::Replicated(_) => stage == PublicationStage::Observed, + }) + } + + fn completion_prerequisite_satisfied(&self) -> Result { + match self.effect { + ProjectionEffect::PublishAccountEvent { .. } => { + if self.checkpoint.is_none() || self.publication_policy.is_none() { + return Ok(false); + } + self.publish_completion_evidence_sufficient() + } + ProjectionEffect::RotateGroupKeys { .. } => { + Ok(self.phase == OperationalEffectPhase::RotationCommitted) + } + ProjectionEffect::NotifyAccountChanged { .. } + | ProjectionEffect::NotifyForkDetected { .. } => { + Ok(self.phase == OperationalEffectPhase::PeersNotified) + } + } + } + + fn resumable_phase(&self) -> Result { + match self.effect { + ProjectionEffect::PublishAccountEvent { .. } => { + let stage = self.validate_publication_evidence()?; + Ok(match stage { + PublicationStage::Observed => OperationalEffectPhase::Observed, + PublicationStage::Replicated => OperationalEffectPhase::Replicated, + PublicationStage::Published => OperationalEffectPhase::Published, + PublicationStage::Authorized => OperationalEffectPhase::CheckpointAuthorized, + PublicationStage::Draft if self.checkpoint_body.is_some() => { + OperationalEffectPhase::CheckpointDraft + } + PublicationStage::Draft => OperationalEffectPhase::Claimed, + }) + } + ProjectionEffect::RotateGroupKeys { .. } if self.rotation_epoch.is_some() => { + Ok(OperationalEffectPhase::RotationCommitted) + } + ProjectionEffect::NotifyAccountChanged { .. } + | ProjectionEffect::NotifyForkDetected { .. } + if self + .audit + .iter() + .any(|audit| audit.phase == OperationalEffectPhase::PeersNotified) => + { + Ok(OperationalEffectPhase::PeersNotified) + } + ProjectionEffect::RotateGroupKeys { .. } + | ProjectionEffect::NotifyAccountChanged { .. } + | ProjectionEffect::NotifyForkDetected { .. } => Ok(OperationalEffectPhase::Claimed), + } + } +} + +const fn checkpoint_phase_rank(phase: OperationalEffectPhase) -> Option { + match phase { + OperationalEffectPhase::Claimed => Some(0), + OperationalEffectPhase::CheckpointDraft => Some(1), + OperationalEffectPhase::CheckpointAuthorized => Some(2), + OperationalEffectPhase::Published => Some(3), + OperationalEffectPhase::Replicated => Some(4), + OperationalEffectPhase::Observed => Some(5), + OperationalEffectPhase::Completed => Some(6), + OperationalEffectPhase::RotationCommitted + | OperationalEffectPhase::PeersNotified + | OperationalEffectPhase::RetryScheduled + | OperationalEffectPhase::TerminalFailure => None, + } +} + +fn retain_checkpoint_phase( + current: OperationalEffectPhase, + candidate: OperationalEffectPhase, +) -> OperationalEffectPhase { + match ( + checkpoint_phase_rank(current), + checkpoint_phase_rank(candidate), + ) { + (Some(current_rank), Some(candidate_rank)) if current_rank >= candidate_rank => current, + _ => candidate, + } +} + +/// Atomic persistence contract for operational effect substeps. +pub trait OperationalEffectStore: Clone + Send + Sync { + /// Load one stable effect journal, distinguishing absence from corruption. + fn load(&self, effect_id: EffectId) -> Result, IdentityError>; + + /// Create or replace one journal under an exact optional revision CAS. + fn compare_and_store( + &self, + effect_id: EffectId, + expected_revision: Option, + next: OperationalEffectRecord, + ) -> Result<(), IdentityError>; +} + +/// In-memory atomic operational effect store. +#[derive(Debug, Clone, Default)] +pub struct MemoryOperationalEffectStore { + records: Arc>>, +} + +impl MemoryOperationalEffectStore { + /// Create an empty in-memory operational journal. + pub fn new() -> Self { + Self::default() + } + + /// Aggregate private-safe operational counters without identifier labels. + pub fn metrics(&self) -> Result { + let records = self.lock_records()?; + Ok(OperationalMetricsSnapshot::from_records(records.values())) + } + + fn lock_records( + &self, + ) -> Result>, IdentityError> { + self.records + .lock() + .map_err(|_| IdentityError::StorageCorruption) + } +} + +impl OperationalEffectStore for MemoryOperationalEffectStore { + fn load(&self, effect_id: EffectId) -> Result, IdentityError> { + let record = self.lock_records()?.get(&effect_id).cloned(); + if let Some(retained) = &record { + retained.validate()?; + } + Ok(record) + } + + fn compare_and_store( + &self, + effect_id: EffectId, + expected_revision: Option, + next: OperationalEffectRecord, + ) -> Result<(), IdentityError> { + next.validate()?; + if next.effect_id != effect_id { + return Err(IdentityError::InvalidRelationship { + resource: "operational effect identifier", + }); + } + let mut records = self.lock_records()?; + let retained_revision = records + .get(&effect_id) + .map(OperationalEffectRecord::revision); + if retained_revision != expected_revision { + return Err(IdentityError::StaleRevision); + } + records.insert(effect_id, next); + Ok(()) + } +} + +/// Runtime-independent coordinator for crash-reconcilable operational substeps. +#[derive(Debug, Clone)] +pub struct OperationalEffectJournal { + store: S, +} + +impl OperationalEffectJournal { + /// Attach the coordinator to one atomic journal implementation. + pub const fn new(store: S) -> Self { + Self { store } + } + + /// Idempotently begin journaling one exclusively claimed Task 6 effect. + pub fn begin( + &self, + effect: &EffectRecord, + recorded_at: Timestamp, + ) -> Result { + if effect.status() != EffectStatus::Claimed { + return Err(IdentityError::InvalidRelationship { + resource: "operational effect claim lifecycle", + }); + } + let lease_id = effect + .execution_lease_id() + .ok_or(IdentityError::InvalidRelationship { + resource: "operational effect claim lease", + })?; + for _ in 0..=MAX_RETRIES { + let Some(mut retained) = self.store.load(effect.id())? else { + let record = OperationalEffectRecord { + revision: 1, + effect_id: effect.id(), + account_id: effect.account_id(), + effect: effect.effect(), + lease_id, + phase: OperationalEffectPhase::Claimed, + checkpoint_body: None, + checkpoint: None, + publication_policy: None, + provider_receipts: Vec::new(), + rotation_epoch: None, + attempt_count: effect.attempt_count(), + last_failure: effect.last_failure(), + audit: vec![OperationalAuditRecord { + sequence: 1, + phase: OperationalEffectPhase::Claimed, + recorded_at, + }], + }; + match self + .store + .compare_and_store(effect.id(), None, record.clone()) + { + Ok(()) => return Ok(record), + Err(IdentityError::StaleRevision) => continue, + Err(error) => return Err(error), + } + }; + if retained.account_id != effect.account_id() || retained.effect != effect.effect() { + return Err(IdentityError::InvalidRelationship { + resource: "operational effect claimed retry", + }); + } + if retained.lease_id == lease_id { + if retained.attempt_count != effect.attempt_count() + || retained.last_failure != effect.last_failure() + { + return Err(IdentityError::InvalidRelationship { + resource: "operational effect claimed attempt", + }); + } + return Ok(retained); + } + let expected_attempt = + retained + .attempt_count + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "operational effect attempt count", + })?; + if effect.attempt_count() != expected_attempt + || effect.last_failure() != retained.last_failure + || matches!( + retained.phase, + OperationalEffectPhase::Completed | OperationalEffectPhase::TerminalFailure + ) + { + return Err(IdentityError::InvalidRelationship { + resource: "operational effect claimed retry", + }); + } + if retained.audit.len() == MAX_OPERATION_AUDIT_RECORDS { + return Err(IdentityError::limit( + "operational effect audit records", + retained.audit.len().saturating_add(1), + MAX_OPERATION_AUDIT_RECORDS, + )); + } + let expected_revision = retained.revision; + let revision = + expected_revision + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "operational effect journal revision", + })?; + let next_phase = if retained.phase == OperationalEffectPhase::RetryScheduled { + retained.resumable_phase()? + } else { + retained.phase + }; + retained.revision = revision; + retained.lease_id = lease_id; + retained.phase = next_phase; + retained.attempt_count = effect.attempt_count(); + retained.last_failure = effect.last_failure(); + retained.audit.push(OperationalAuditRecord { + sequence: revision, + phase: next_phase, + recorded_at, + }); + match self.store.compare_and_store( + effect.id(), + Some(expected_revision), + retained.clone(), + ) { + Ok(()) => return Ok(retained), + Err(IdentityError::StaleRevision) => continue, + Err(error) => return Err(error), + } + } + Err(IdentityError::ResourceBusy) + } + + /// Retain the deterministic checkpoint body before authorization or provider publication. + pub fn record_checkpoint_draft( + &self, + effect_id: EffectId, + body: CheckpointBody, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| { + let ProjectionEffect::PublishAccountEvent { event_id } = record.effect else { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint draft effect kind", + }); + }; + if body.account_id() != record.account_id || body.event_head() != event_id { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint draft effect subject", + }); + } + if record + .checkpoint_body + .as_ref() + .is_some_and(|retained| retained != &body) + { + return Err(IdentityError::InvalidProof); + } + record.checkpoint_body = Some(body.clone()); + Ok(retain_checkpoint_phase( + record.phase, + OperationalEffectPhase::CheckpointDraft, + )) + }) + } + + /// Retain one verified checkpoint before its revision-bound Task 6 store commit. + pub fn record_checkpoint_authorized( + &self, + effect_id: EffectId, + checkpoint: &VerifiedCheckpoint, + provider_policy: &ProviderPolicy, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| { + let body = checkpoint.checkpoint().body(); + if record.checkpoint_body.as_ref() != Some(body) { + return Err(IdentityError::InvalidRelationship { + resource: "authorized checkpoint draft", + }); + } + if provider_policy.id()? != body.provider_policy_id() + || record + .publication_policy + .as_ref() + .is_some_and(|retained| retained != provider_policy) + { + return Err(IdentityError::PolicyVersionMismatch); + } + record.checkpoint = Some(match record.checkpoint.as_ref() { + Some(retained) => retained.merge(checkpoint.checkpoint())?, + None => checkpoint.checkpoint().clone(), + }); + record.publication_policy = Some(provider_policy.clone()); + Ok(retain_checkpoint_phase( + record.phase, + OperationalEffectPhase::CheckpointAuthorized, + )) + }) + } + + /// Journal exact tracker-verified publication receipts without overstating threshold stage. + pub fn record_publications( + &self, + effect_id: EffectId, + tracker: &PublicationTracker, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| { + validate_tracker_subject(record, tracker)?; + if record + .publication_policy + .as_ref() + .is_some_and(|retained| retained != tracker.provider_policy()) + { + return Err(IdentityError::PolicyVersionMismatch); + } + record.publication_policy = Some(tracker.provider_policy().clone()); + if tracker.stage() == PublicationStage::Draft { + return Err(IdentityError::InvalidRelationship { + resource: "operational publication before authorization", + }); + } + for publication in tracker.publication_receipts() { + let provider = tracker + .configured_providers() + .iter() + .find(|provider| provider.id() == Ok(publication.provider_id())) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + let candidate = OperationalProviderReceipt { + provider, + publication: publication.clone(), + observation: None, + }; + candidate.validate()?; + if let Some(retained) = record + .provider_receipts + .iter() + .find(|retained| retained.provider.id() == candidate.provider.id()) + { + if retained.publication == candidate.publication { + continue; + } + if retained.publication.entry().log_id() + == candidate.publication.entry().log_id() + && retained.publication.signed_head().body().tree_size() + == candidate.publication.signed_head().body().tree_size() + && retained.publication.signed_head().body().tree_root() + != candidate.publication.signed_head().body().tree_root() + { + return Err(IdentityError::ProviderEquivocation); + } + // Re-publication at a later leaf is valid availability evidence, but the + // first durable baseline remains authoritative for later observations. + continue; + } + record.provider_receipts.push(candidate); + } + record + .provider_receipts + .sort_unstable_by_key(|retained| retained.provider.id().ok()); + let phase = match record.validate_publication_evidence()? { + PublicationStage::Draft => OperationalEffectPhase::CheckpointDraft, + PublicationStage::Authorized => OperationalEffectPhase::CheckpointAuthorized, + PublicationStage::Published => OperationalEffectPhase::Published, + PublicationStage::Replicated => OperationalEffectPhase::Replicated, + PublicationStage::Observed => OperationalEffectPhase::Observed, + }; + Ok(retain_checkpoint_phase(record.phase, phase)) + }) + } + + /// Journal one exact later observation and its consistency proof after tracker verification. + pub fn record_observation( + &self, + effect_id: EffectId, + tracker: &PublicationTracker, + receipt: InclusionReceipt, + proof: MerkleConsistencyProof, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| { + validate_tracker_subject(record, tracker)?; + if record.publication_policy.as_ref() != Some(tracker.provider_policy()) { + return Err(IdentityError::PolicyVersionMismatch); + } + if !tracker + .observation_receipts() + .iter() + .any(|retained| retained == &receipt) + { + return Err(IdentityError::InvalidRelationship { + resource: "operational unverified observation", + }); + } + let retained = record + .provider_receipts + .iter_mut() + .find(|retained| retained.provider.id() == Ok(receipt.provider_id())) + .ok_or(IdentityError::InvalidRelationship { + resource: "operational observation before publication", + })?; + let candidate = OperationalProviderReceipt { + provider: retained.provider.clone(), + publication: retained.publication.clone(), + observation: Some((receipt.clone(), proof.clone())), + }; + candidate.validate()?; + if let (Some(prior), Some(next)) = (&retained.observation, &candidate.observation) + && prior != next + { + return Err(IdentityError::InvalidProof); + } + *retained = candidate; + let phase = match record.validate_publication_evidence()? { + PublicationStage::Draft | PublicationStage::Authorized => { + return Err(IdentityError::InvalidRelationship { + resource: "operational observation publication stage", + }); + } + PublicationStage::Published => OperationalEffectPhase::Published, + PublicationStage::Replicated => OperationalEffectPhase::Replicated, + PublicationStage::Observed => OperationalEffectPhase::Observed, + }; + Ok(retain_checkpoint_phase(record.phase, phase)) + }) + } + + /// Reconcile a revision-bound Task 6 group-key rotation completion. + pub fn record_rotation_committed( + &self, + effect_id: EffectId, + rotation: &StoredGroupKeyRotation, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| { + let ProjectionEffect::RotateGroupKeys { epoch, .. } = record.effect else { + return Err(IdentityError::InvalidRelationship { + resource: "operational rotation effect kind", + }); + }; + if rotation.account_id() != record.account_id + || rotation.authorizing_account_epoch() != epoch + { + return Err(IdentityError::InvalidRelationship { + resource: "operational rotation effect subject", + }); + } + record.rotation_epoch = Some(epoch); + Ok(OperationalEffectPhase::RotationCommitted) + }) + } + + /// Retain completion of one idempotent peer-notification effect. + pub fn record_peers_notified( + &self, + effect_id: EffectId, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| match record.effect { + ProjectionEffect::NotifyAccountChanged { .. } + | ProjectionEffect::NotifyForkDetected { .. } => { + Ok(OperationalEffectPhase::PeersNotified) + } + _ => Err(IdentityError::InvalidRelationship { + resource: "operational notification effect kind", + }), + }) + } + + /// Reconcile Task 6 completion only after the effect-specific terminal prerequisite. + pub fn record_completed( + &self, + effect_id: EffectId, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| { + let ready = record.completion_prerequisite_satisfied()?; + if !ready && record.phase != OperationalEffectPhase::Completed { + return Err(IdentityError::InvalidRelationship { + resource: "operational completion prerequisite", + }); + } + Ok(OperationalEffectPhase::Completed) + }) + } + + /// Retain a retryable or permanent failure without changing prior publication evidence. + pub fn record_failure( + &self, + effect_id: EffectId, + attempt_count: u8, + failure: EffectFailure, + recorded_at: Timestamp, + ) -> Result { + self.update(effect_id, recorded_at, |record| { + if attempt_count == 0 || attempt_count > MAX_RETRIES { + return Err(IdentityError::limit( + "operational effect attempts", + usize::from(attempt_count), + usize::from(MAX_RETRIES), + )); + } + if attempt_count != record.attempt_count { + return Err(IdentityError::InvalidRelationship { + resource: "operational failure attempt", + }); + } + record.attempt_count = attempt_count; + record.last_failure = Some(failure); + Ok(match failure { + EffectFailure::Transient(_) if attempt_count < MAX_RETRIES => { + OperationalEffectPhase::RetryScheduled + } + EffectFailure::Transient(_) | EffectFailure::Permanent(_) => { + OperationalEffectPhase::TerminalFailure + } + }) + }) + } + + /// Load one durable operational record. + pub fn load( + &self, + effect_id: EffectId, + ) -> Result, IdentityError> { + self.store.load(effect_id) + } + + fn update( + &self, + effect_id: EffectId, + recorded_at: Timestamp, + mut update: impl FnMut( + &mut OperationalEffectRecord, + ) -> Result, + ) -> Result { + for _ in 0..=MAX_RETRIES { + let mut record = + self.store + .load(effect_id)? + .ok_or(IdentityError::InvalidRelationship { + resource: "operational effect journal", + })?; + let original = record.clone(); + let expected_revision = record.revision; + let next_phase = update(&mut record)?; + if record == original && original.phase == next_phase { + return Ok(original); + } + if !valid_phase_transition(original.phase, next_phase) { + return Err(IdentityError::InvalidRelationship { + resource: "operational effect phase transition", + }); + } + if record.audit.len() == MAX_OPERATION_AUDIT_RECORDS { + return Err(IdentityError::limit( + "operational effect audit records", + record.audit.len().saturating_add(1), + MAX_OPERATION_AUDIT_RECORDS, + )); + } + let revision = + expected_revision + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "operational effect journal revision", + })?; + record.revision = revision; + record.phase = next_phase; + record.audit.push(OperationalAuditRecord { + sequence: revision, + phase: next_phase, + recorded_at, + }); + match self + .store + .compare_and_store(effect_id, Some(expected_revision), record.clone()) + { + Ok(()) => return Ok(record), + Err(IdentityError::StaleRevision) => continue, + Err(error) => return Err(error), + } + } + Err(IdentityError::ResourceBusy) + } +} + +/// External signing boundary for the exact deterministic checkpoint body built by the driver. +pub trait OperationalCheckpointAuthorizer: Send + Sync { + /// Authorize the exact supplied body without rebuilding or mutating it. + fn authorize<'a>(&'a self, body: &'a CheckpointBody) -> StoreFuture<'a, SignedCheckpoint>; +} + +/// External deterministic group-key rotation boundary. +pub trait OperationalGroupKeyRotator: Send + Sync { + /// Produce the exact revision-bound rotation artifact for one claimed effect. + fn rotate<'a>( + &'a self, + effect: &'a EffectRecord, + snapshot: &'a AccountSnapshot, + ) -> StoreFuture<'a, GroupKeyRotation>; +} + +/// External idempotent peer-notification boundary. +pub trait OperationalPeerNotifier: Send + Sync { + /// Notify peers of the exact claimed effect; implementations own bounded transport deadlines. + fn notify<'a>(&'a self, effect: &'a EffectRecord) -> StoreFuture<'a, ()>; +} + +/// Verified checkpoint plus its revision-bound durable store commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationalCheckpointCommit { + checkpoint: VerifiedCheckpoint, + receipt: CheckpointCommitReceipt, +} + +impl OperationalCheckpointCommit { + /// Opaque checkpoint reverified against the exact account projection. + pub const fn checkpoint(&self) -> &VerifiedCheckpoint { + &self.checkpoint + } + + /// Atomic Task 6 checkpoint-journal commit receipt. + pub const fn receipt(&self) -> &CheckpointCommitReceipt { + &self.receipt + } +} + +/// Exact deterministic inputs for checkpoint build, authorization, and store reconciliation. +#[derive(Debug, Clone, Copy)] +pub struct OperationalCheckpointBuild<'a> { + effect_id: EffectId, + snapshot: &'a AccountSnapshot, + issued_at: Timestamp, + transition_event: Option<&'a AuthorizedEvent>, + draft_recorded_at: Timestamp, + authorized_recorded_at: Timestamp, +} + +impl<'a> OperationalCheckpointBuild<'a> { + /// Bind all checkpoint substeps to one exact Task 6 effect and account revision. + pub const fn new( + effect_id: EffectId, + snapshot: &'a AccountSnapshot, + issued_at: Timestamp, + transition_event: Option<&'a AuthorizedEvent>, + draft_recorded_at: Timestamp, + authorized_recorded_at: Timestamp, + ) -> Self { + Self { + effect_id, + snapshot, + issued_at, + transition_event, + draft_recorded_at, + authorized_recorded_at, + } + } +} + +/// Build, journal, authorize, verify, and revision-bind one checkpoint idempotently. +pub async fn build_authorize_and_commit_checkpoint( + account_store: &A, + journal: &OperationalEffectJournal, + authorizer: &H, + build: OperationalCheckpointBuild<'_>, +) -> Result +where + A: AccountStore + ?Sized, + J: OperationalEffectStore, + H: OperationalCheckpointAuthorizer + ?Sized, +{ + let body = build_checkpoint_body(build.snapshot.state(), build.issued_at)?; + journal.record_checkpoint_draft(build.effect_id, body.clone(), build.draft_recorded_at)?; + let retained = journal.load(build.effect_id)?; + let verified = if let Some(checkpoint) = + retained.and_then(|record| record.checkpoint().cloned()) + { + verify_checkpoint(build.snapshot.state(), &checkpoint, build.transition_event)? + } else { + let signed = authorizer.authorize(&body).await?; + let verified = verify_checkpoint(build.snapshot.state(), &signed, build.transition_event)?; + let record = journal.record_checkpoint_authorized( + build.effect_id, + &verified, + build.snapshot.state().provider_policy(), + build.authorized_recorded_at, + )?; + let checkpoint = record + .checkpoint() + .ok_or(IdentityError::StorageCorruption)?; + verify_checkpoint(build.snapshot.state(), checkpoint, build.transition_event)? + }; + let receipt = account_store + .commit_checkpoint(build.snapshot.revision().clone(), verified.clone()) + .await?; + let committed = receipt + .snapshot() + .checkpoints() + .iter() + .find(|checkpoint| { + checkpoint + .checkpoint_id() + .is_ok_and(|id| id == verified.checkpoint_id()) + }) + .ok_or(IdentityError::StorageCorruption)?; + let committed = verify_checkpoint(build.snapshot.state(), committed, build.transition_event)?; + let record = journal.record_checkpoint_authorized( + build.effect_id, + &committed, + build.snapshot.state().provider_policy(), + build.authorized_recorded_at, + )?; + let checkpoint = record + .checkpoint() + .ok_or(IdentityError::StorageCorruption)?; + let verified = verify_checkpoint(build.snapshot.state(), checkpoint, build.transition_event)?; + Ok(OperationalCheckpointCommit { + checkpoint: verified, + receipt, + }) +} + +/// Publish concurrently, then journal only the exact tracker-verified receipts and honest stage. +pub async fn publish_and_journal_checkpoint( + journal: &OperationalEffectJournal, + effect_id: EffectId, + tracker: &mut PublicationTracker, + checkpoint: &ProviderCheckpointBundle, + clients: &[&dyn TransparencyClient], + recorded_at: Timestamp, +) -> Result +where + J: OperationalEffectStore, +{ + let batch = publish_checkpoint_concurrently(tracker, checkpoint, clients).await?; + journal.record_publications(effect_id, tracker, recorded_at)?; + Ok(batch) +} + +/// Produce and atomically commit a revision-bound group rotation, then reconcile its journal. +pub async fn rotate_and_journal_group_keys( + account_store: &A, + journal: &OperationalEffectJournal, + effect: &EffectRecord, + snapshot: &AccountSnapshot, + rotator: &R, + completed_at: Timestamp, +) -> Result +where + A: AccountStore + ?Sized, + J: OperationalEffectStore, + R: OperationalGroupKeyRotator + ?Sized, +{ + let lease_id = effect + .execution_lease_id() + .ok_or(IdentityError::InvalidRelationship { + resource: "operational rotation lease", + })?; + let rotation = rotator.rotate(effect, snapshot).await?; + let stored = account_store + .commit_group_key_rotation(effect.id(), lease_id, rotation, completed_at) + .await?; + journal.record_rotation_committed(effect.id(), &stored, completed_at)?; + journal.record_completed(effect.id(), completed_at)?; + Ok(stored) +} + +/// Notify peers, complete Task 6 idempotently, and reconcile the terminal journal record. +pub async fn notify_and_complete_effect( + account_store: &A, + journal: &OperationalEffectJournal, + effect: &EffectRecord, + notifier: &N, + completed_at: Timestamp, +) -> Result<(), IdentityError> +where + A: AccountStore + ?Sized, + J: OperationalEffectStore, + N: OperationalPeerNotifier + ?Sized, +{ + let lease_id = effect + .execution_lease_id() + .ok_or(IdentityError::InvalidRelationship { + resource: "operational notification lease", + })?; + notifier.notify(effect).await?; + journal.record_peers_notified(effect.id(), completed_at)?; + account_store + .complete_effect(effect.account_id(), effect.id(), lease_id, completed_at) + .await?; + journal.record_completed(effect.id(), completed_at)?; + Ok(()) +} + +/// Complete any phase-ready Task 6 effect and reconcile the journal after crashes. +pub async fn complete_ready_effect( + account_store: &A, + journal: &OperationalEffectJournal, + effect: &EffectRecord, + completed_at: Timestamp, +) -> Result<(), IdentityError> +where + A: AccountStore + ?Sized, + J: OperationalEffectStore, +{ + let operational = journal + .load(effect.id())? + .ok_or(IdentityError::InvalidRelationship { + resource: "operational completion journal", + })?; + let lease_id = effect + .execution_lease_id() + .ok_or(IdentityError::InvalidRelationship { + resource: "operational completion lease", + })?; + if operational.account_id != effect.account_id() + || operational.effect != effect.effect() + || operational.lease_id != lease_id + { + return Err(IdentityError::InvalidRelationship { + resource: "operational completion effect", + }); + } + if !operational.completion_prerequisite_satisfied()? { + return Err(IdentityError::InvalidRelationship { + resource: "operational completion prerequisite", + }); + } + account_store + .complete_effect(effect.account_id(), effect.id(), lease_id, completed_at) + .await?; + journal.record_completed(effect.id(), completed_at)?; + Ok(()) +} + +/// Aggregate-only operational counters safe for metric labels and dashboards. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct OperationalMetricsSnapshot { + pending: u64, + completed: u64, + retry_scheduled: u64, + terminal_failures: u64, + publication_shortfalls: u64, +} + +impl OperationalMetricsSnapshot { + /// Effects not yet terminal. + pub const fn pending(self) -> u64 { + self.pending + } + + /// Successfully reconciled effects. + pub const fn completed(self) -> u64 { + self.completed + } + + /// Effects awaiting a bounded retry. + pub const fn retry_scheduled(self) -> u64 { + self.retry_scheduled + } + + /// Effects retained for terminal operator action. + pub const fn terminal_failures(self) -> u64 { + self.terminal_failures + } + + /// Authorized or partially published checkpoints below observation completion. + pub const fn publication_shortfalls(self) -> u64 { + self.publication_shortfalls + } + + fn from_records<'a>(records: impl Iterator) -> Self { + let mut metrics = Self::default(); + for record in records { + match record.phase { + OperationalEffectPhase::Completed => metrics.completed += 1, + OperationalEffectPhase::RetryScheduled => { + metrics.pending += 1; + metrics.retry_scheduled += 1; + } + OperationalEffectPhase::TerminalFailure => metrics.terminal_failures += 1, + OperationalEffectPhase::CheckpointAuthorized => { + metrics.pending += 1; + if !record + .publication_policy + .as_ref() + .is_some_and(|policy| matches!(policy.mode(), ProviderMode::LocalOnly)) + { + metrics.publication_shortfalls += 1; + } + } + OperationalEffectPhase::Published | OperationalEffectPhase::Replicated => { + metrics.pending += 1; + metrics.publication_shortfalls += 1; + } + _ => metrics.pending += 1, + } + } + metrics + } +} + +fn validate_tracker_subject( + record: &OperationalEffectRecord, + tracker: &PublicationTracker, +) -> Result<(), IdentityError> { + let checkpoint = record + .checkpoint + .as_ref() + .ok_or(IdentityError::InvalidRelationship { + resource: "operational publication checkpoint", + })?; + if tracker.account_id() != record.account_id + || tracker.checkpoint_id() != checkpoint.checkpoint_id()? + || tracker.provider_policy_id() != checkpoint.body().provider_policy_id() + || tracker.provider_policy().id()? != tracker.provider_policy_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "operational publication tracker subject", + }); + } + for receipt in tracker.publication_receipts() { + if receipt.entry().account_id() != record.account_id + || receipt.entry().subject() != ProviderLogSubject::Checkpoint(tracker.checkpoint_id()) + { + return Err(IdentityError::InvalidRelationship { + resource: "operational publication receipt subject", + }); + } + } + Ok(()) +} diff --git a/protocols/krikos-identity/src/operations/redb.rs b/protocols/krikos-identity/src/operations/redb.rs new file mode 100644 index 00000000000..b1b27dfa656 --- /dev/null +++ b/protocols/krikos-identity/src/operations/redb.rs @@ -0,0 +1,388 @@ +//! Redb persistence for Task 6 operational effect substeps. + +use std::{path::Path, sync::Arc}; + +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; + +use super::{ + MAX_OPERATION_AUDIT_RECORDS, OperationalAuditRecord, OperationalEffectPhase, + OperationalEffectRecord, OperationalEffectStore, OperationalMetricsSnapshot, + OperationalProviderReceipt, +}; +use crate::{ + AccountId, CheckpointBody, EffectFailure, EffectId, Epoch, EventId, IdentityError, + InclusionReceipt, LeaseId, ProjectionEffect, ProviderDescriptor, ProviderPolicy, + SignedCheckpoint, Timestamp, + codec::{decode_wire, encode_wire}, + limits::{MAX_RETRIES, MAX_TRANSPARENCY_PROVIDERS}, + merkle::MerkleConsistencyProof, + schema::BoundedVec, +}; + +const OPERATION_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-operational-effects-v1"); +const OPERATION_VERSION: u16 = 2; +const MAX_OPERATION_RECORD_BYTES: usize = 32 * 1024 * 1024; +const MAX_OPERATION_RECORDS: usize = 65_536; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderReceiptWire { + provider: ProviderDescriptor, + publication: InclusionReceipt, + observation: Option<(InclusionReceipt, MerkleConsistencyProof)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct AuditWire { + sequence: u64, + phase_code: u16, + recorded_at: Timestamp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct OperationWire { + version: u16, + revision: u64, + effect_id: [u8; 32], + account_id: AccountId, + effect_code: u16, + event_id: EventId, + effect_epoch: Option, + lease_id: [u8; 16], + phase_code: u16, + checkpoint_body: Option, + checkpoint: Option, + publication_policy: Option, + provider_receipts: BoundedVec, + rotation_epoch: Option, + attempt_count: u8, + last_failure: Option<(u16, u16)>, + audit: BoundedVec, +} + +impl OperationWire { + fn from_record(record: &OperationalEffectRecord) -> Result { + record.validate()?; + let (effect_code, event_id, effect_epoch) = match record.effect { + ProjectionEffect::PublishAccountEvent { event_id } => (1, event_id, None), + ProjectionEffect::RotateGroupKeys { event_id, epoch } => (2, event_id, Some(epoch)), + ProjectionEffect::NotifyAccountChanged { event_id } => (3, event_id, None), + ProjectionEffect::NotifyForkDetected { event_id } => (4, event_id, None), + }; + let last_failure = record.last_failure.map(|failure| match failure { + EffectFailure::Transient(code) => (1, code), + EffectFailure::Permanent(code) => (2, code), + }); + Ok(Self { + version: OPERATION_VERSION, + revision: record.revision, + effect_id: *record.effect_id.as_bytes(), + account_id: record.account_id, + effect_code, + event_id, + effect_epoch, + lease_id: *record.lease_id.as_bytes(), + phase_code: phase_code(record.phase), + checkpoint_body: record.checkpoint_body.clone(), + checkpoint: record.checkpoint.clone(), + publication_policy: record.publication_policy.clone(), + provider_receipts: BoundedVec::new( + "stored operational provider receipts", + record + .provider_receipts + .iter() + .map(|receipt| ProviderReceiptWire { + provider: receipt.provider.clone(), + publication: receipt.publication.clone(), + observation: receipt.observation.clone(), + }) + .collect(), + )?, + rotation_epoch: record.rotation_epoch, + attempt_count: record.attempt_count, + last_failure, + audit: BoundedVec::new( + "stored operational audit records", + record + .audit + .iter() + .map(|audit| AuditWire { + sequence: audit.sequence, + phase_code: phase_code(audit.phase), + recorded_at: audit.recorded_at, + }) + .collect(), + )?, + }) + } + + fn into_record(self) -> Result { + if self.version != OPERATION_VERSION + || self.attempt_count == 0 + || self.attempt_count > MAX_RETRIES + { + return Err(IdentityError::StorageCorruption); + } + let effect = match (self.effect_code, self.effect_epoch) { + (1, None) => ProjectionEffect::PublishAccountEvent { + event_id: self.event_id, + }, + (2, Some(epoch)) => ProjectionEffect::RotateGroupKeys { + event_id: self.event_id, + epoch, + }, + (3, None) => ProjectionEffect::NotifyAccountChanged { + event_id: self.event_id, + }, + (4, None) => ProjectionEffect::NotifyForkDetected { + event_id: self.event_id, + }, + _ => return Err(IdentityError::StorageCorruption), + }; + let last_failure = match self.last_failure { + None => None, + Some((1, code)) => { + Some(EffectFailure::transient(code).map_err(|_| IdentityError::StorageCorruption)?) + } + Some((2, code)) => { + Some(EffectFailure::permanent(code).map_err(|_| IdentityError::StorageCorruption)?) + } + Some(_) => return Err(IdentityError::StorageCorruption), + }; + let record = OperationalEffectRecord { + revision: self.revision, + effect_id: EffectId::from_bytes(self.effect_id), + account_id: self.account_id, + effect, + lease_id: LeaseId::new(self.lease_id).map_err(|_| IdentityError::StorageCorruption)?, + phase: decode_phase(self.phase_code)?, + checkpoint_body: self.checkpoint_body, + checkpoint: self.checkpoint, + publication_policy: self.publication_policy, + provider_receipts: self + .provider_receipts + .into_vec() + .into_iter() + .map(|wire| { + Ok(OperationalProviderReceipt { + provider: wire.provider, + publication: wire.publication, + observation: wire.observation, + }) + }) + .collect::, IdentityError>>()?, + rotation_epoch: self.rotation_epoch, + attempt_count: self.attempt_count, + last_failure, + audit: self + .audit + .into_vec() + .into_iter() + .map(|wire| { + Ok(OperationalAuditRecord { + sequence: wire.sequence, + phase: decode_phase(wire.phase_code)?, + recorded_at: wire.recorded_at, + }) + }) + .collect::, IdentityError>>()?, + }; + record.validate()?; + Ok(record) + } +} + +/// Redb-backed atomic operational effect journal keyed by Task 6 stable effect IDs. +#[derive(Debug, Clone)] +pub struct RedbOperationalEffectStore { + database: Arc, +} + +impl RedbOperationalEffectStore { + /// Open or create an operational journal and authenticate every retained substep record. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + crate::redb_guard::validate_existing_redb_file(path)?; + let database = Database::create(path).map_err(|_| IdentityError::StorageCorruption)?; + { + let write = database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let _ = write + .open_table(OPERATION_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + } + let store = Self { + database: Arc::new(database), + }; + store.validate_all()?; + Ok(store) + } + + /// Aggregate private-safe metrics without using any durable identifier as a label. + pub fn metrics(&self) -> Result { + let records = self.load_all()?; + Ok(OperationalMetricsSnapshot::from_records(records.iter())) + } + + fn validate_all(&self) -> Result<(), IdentityError> { + let records = self.load_all()?; + if records.len() > MAX_OPERATION_RECORDS { + return Err(IdentityError::StorageCorruption); + } + Ok(()) + } + + fn load_all(&self) -> Result, IdentityError> { + let read = self + .database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let table = read + .open_table(OPERATION_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let mut records = Vec::new(); + for entry in table.iter().map_err(|_| IdentityError::StorageCorruption)? { + let (key, value) = entry.map_err(|_| IdentityError::StorageCorruption)?; + let key: [u8; 32] = key + .value() + .try_into() + .map_err(|_| IdentityError::StorageCorruption)?; + let record = decode_record(value.value())?; + if record.effect_id != EffectId::from_bytes(key) { + return Err(IdentityError::StorageCorruption); + } + records.push(record); + if records.len() > MAX_OPERATION_RECORDS { + return Err(IdentityError::StorageCorruption); + } + } + Ok(records) + } +} + +impl OperationalEffectStore for RedbOperationalEffectStore { + fn load(&self, effect_id: EffectId) -> Result, IdentityError> { + let read = self + .database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let table = read + .open_table(OPERATION_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .get(effect_id.as_bytes().as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| decode_record(value.value())) + .transpose() + } + + fn compare_and_store( + &self, + effect_id: EffectId, + expected_revision: Option, + next: OperationalEffectRecord, + ) -> Result<(), IdentityError> { + next.validate()?; + if next.effect_id != effect_id { + return Err(IdentityError::InvalidRelationship { + resource: "operational effect identifier", + }); + } + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + { + let mut table = write + .open_table(OPERATION_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let retained = table + .get(effect_id.as_bytes().as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| decode_record(value.value())) + .transpose()?; + if retained.as_ref().map(OperationalEffectRecord::revision) != expected_revision { + return Err(IdentityError::StaleRevision); + } + if retained.is_none() { + let count = table + .iter() + .map_err(|_| IdentityError::StorageCorruption)? + .count(); + if count >= MAX_OPERATION_RECORDS { + return Err(IdentityError::limit( + "stored operational effects", + count.saturating_add(1), + MAX_OPERATION_RECORDS, + )); + } + } + let bytes = encode_record(&next)?; + table + .insert(effect_id.as_bytes().as_slice(), bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write.commit().map_err(|_| IdentityError::StorageCorruption) + } +} + +fn encode_record(record: &OperationalEffectRecord) -> Result, IdentityError> { + let bytes = encode_wire(&OperationWire::from_record(record)?) + .map_err(|_| IdentityError::StorageCorruption)?; + if bytes.len() > MAX_OPERATION_RECORD_BYTES { + return Err(IdentityError::limit( + "stored operational effect bytes", + bytes.len(), + MAX_OPERATION_RECORD_BYTES, + )); + } + Ok(bytes) +} + +fn decode_record(bytes: &[u8]) -> Result { + if bytes.len() > MAX_OPERATION_RECORD_BYTES { + return Err(IdentityError::StorageCorruption); + } + decode_wire::(bytes) + .map_err(|_| IdentityError::StorageCorruption)? + .into_record() + .map_err(|_| IdentityError::StorageCorruption) +} + +const fn phase_code(phase: OperationalEffectPhase) -> u16 { + match phase { + OperationalEffectPhase::Claimed => 1, + OperationalEffectPhase::CheckpointDraft => 2, + OperationalEffectPhase::CheckpointAuthorized => 3, + OperationalEffectPhase::Published => 4, + OperationalEffectPhase::Replicated => 5, + OperationalEffectPhase::Observed => 6, + OperationalEffectPhase::RotationCommitted => 7, + OperationalEffectPhase::PeersNotified => 8, + OperationalEffectPhase::RetryScheduled => 9, + OperationalEffectPhase::TerminalFailure => 10, + OperationalEffectPhase::Completed => 11, + } +} + +fn decode_phase(code: u16) -> Result { + match code { + 1 => Ok(OperationalEffectPhase::Claimed), + 2 => Ok(OperationalEffectPhase::CheckpointDraft), + 3 => Ok(OperationalEffectPhase::CheckpointAuthorized), + 4 => Ok(OperationalEffectPhase::Published), + 5 => Ok(OperationalEffectPhase::Replicated), + 6 => Ok(OperationalEffectPhase::Observed), + 7 => Ok(OperationalEffectPhase::RotationCommitted), + 8 => Ok(OperationalEffectPhase::PeersNotified), + 9 => Ok(OperationalEffectPhase::RetryScheduled), + 10 => Ok(OperationalEffectPhase::TerminalFailure), + 11 => Ok(OperationalEffectPhase::Completed), + _ => Err(IdentityError::StorageCorruption), + } +} diff --git a/protocols/krikos-identity/src/pairing.rs b/protocols/krikos-identity/src/pairing.rs new file mode 100644 index 00000000000..bbfc148dbde --- /dev/null +++ b/protocols/krikos-identity/src/pairing.rs @@ -0,0 +1,2023 @@ +//! Pure, bounded device-pairing ceremony schemas and state transitions. + +use std::fmt; + +mod nonce_store; + +use krikos_base::{PublicKey as Ed25519PublicKey, Signature as Ed25519Signature}; +pub use nonce_store::MemoryPairingNonceStore; +#[cfg(feature = "fs-store")] +pub use nonce_store::RedbPairingNonceStore; +use rand_core::TryCryptoRng; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeTuple}; +use x25519_dalek::{PublicKey as X25519PublicKey, SharedSecret, StaticSecret}; +use zeroize::Zeroizing; + +use crate::{ + AccountId, AgreementPublicKey, AgreementSecretKey, CanonicalWire, DeviceAuthorizationProposal, + DeviceDescriptor, DeviceId, Digest, EndpointPublicKey, Extensions, HashAlgorithm, + IdentityError, ProtocolSignature, ProtocolVersion, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{ + MAX_ACCOUNT_EVENT_BYTES, MAX_FUTURE_CLOCK_SKEW, MAX_PAIRING_LIFETIME, + MAX_PAIRING_TICKET_BYTES, + }, + schema::BoundedBytes, +}; + +/// Maximum opaque transport-discovery bytes retained in a pairing ticket. +pub const MAX_PAIRING_ENDPOINT_HINT_BYTES: usize = 1_024; + +const PAIRING_SECRET_COMMITMENT_CONTEXT: &str = "KRIKOS-ID/pairing-secret-commitment/v1"; +const PAIRING_TICKET_ID_CONTEXT: &str = "KRIKOS-ID/pairing-ticket-id/v1"; +const PAIRING_TRANSCRIPT_ID_CONTEXT: &str = "KRIKOS-ID/pairing-transcript-id/v1"; +const PAIRING_PROOF_ID_CONTEXT: &str = "KRIKOS-ID/pairing-possession-proof-id/v1"; +#[cfg(any(test, feature = "net"))] +const PAIRING_TRANSPORT_EXPORTER_CONTEXT: &str = "KRIKOS-ID/pairing-transport-exporter/v1"; +const APPLICATION_POSSESSION_DOMAIN: &[u8] = b"KRIKOS-ID/pairing-application-possession/v1"; +const ENDPOINT_POSSESSION_DOMAIN: &[u8] = b"KRIKOS-ID/pairing-endpoint-possession/v1"; +const AGREEMENT_POSSESSION_DOMAIN: &[u8] = b"KRIKOS-ID/pairing-agreement-possession/v1"; +const EPHEMERAL_POSSESSION_DOMAIN: &[u8] = b"KRIKOS-ID/pairing-ephemeral-possession/v1"; +const PAIRING_CONFIRMATION_DOMAIN: &[u8] = b"KRIKOS-ID/pairing-confirmation/v1"; +const AGREEMENT_PROOF_KEY_CONTEXT: &str = "KRIKOS-ID/pairing-agreement-proof-key/v1"; +const EPHEMERAL_PROOF_KEY_CONTEXT: &str = "KRIKOS-ID/pairing-ephemeral-proof-key/v1"; + +fn derive_digest(context: &'static str, bytes: &[u8]) -> Digest { + Digest::new( + HashAlgorithm::Blake3_256, + blake3::derive_key(context, bytes), + ) +} + +fn validate_ticket_times(issued_at: Timestamp, expires_at: Timestamp) -> Result<(), IdentityError> { + let lifetime = expires_at + .as_unix_millis() + .checked_sub(issued_at.as_unix_millis()) + .ok_or(IdentityError::InvalidRelationship { + resource: "pairing ticket validity interval", + })?; + if lifetime == 0 { + return Err(IdentityError::ZeroValue { + resource: "pairing ticket lifetime", + }); + } + if u128::from(lifetime) > MAX_PAIRING_LIFETIME.as_millis() { + return Err(IdentityError::LimitExceeded { + resource: "pairing ticket lifetime milliseconds", + actual: usize::try_from(lifetime).unwrap_or(usize::MAX), + maximum: usize::try_from(MAX_PAIRING_LIFETIME.as_millis()).unwrap_or(usize::MAX), + }); + } + Ok(()) +} + +/// Nonzero one-time pairing-ticket nonce. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct PairingNonce([u8; 32]); + +impl PairingNonce { + /// Validate an exact 256-bit nonce. + pub fn new(bytes: [u8; 32]) -> Result { + if bytes == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "pairing ticket nonce", + }); + } + Ok(Self(bytes)) + } + + /// Exact nonce bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for PairingNonce { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PairingNonce()") + } +} + +impl<'de> Deserialize<'de> for PairingNonce { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(<[u8; 32]>::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for PairingNonce { + const RESOURCE: &'static str = "pairing nonce bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Domain-separated identifier of a complete canonical pairing ticket. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct PairingTicketId(Digest); + +impl PairingTicketId { + /// Borrow the tagged digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } +} + +impl CanonicalCodec for PairingTicketId { + const RESOURCE: &'static str = "pairing ticket identifier bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Validated public inputs used to issue a pairing ticket. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingTicketRequest { + account_id: AccountId, + proposed_device: DeviceDescriptor, + endpoint_hint: BoundedBytes, + issued_at: Timestamp, + expires_at: Timestamp, + extensions: Extensions, +} + +impl PairingTicketRequest { + /// Construct a request with an explicit validity interval and bounded opaque endpoint hint. + pub fn new( + account_id: AccountId, + proposed_device: DeviceDescriptor, + endpoint_hint: Vec, + issued_at: Timestamp, + expires_at: Timestamp, + extensions: Extensions, + ) -> Result { + validate_ticket_times(issued_at, expires_at)?; + extensions.validate_critical(&[])?; + Ok(Self { + account_id, + proposed_device, + endpoint_hint: BoundedBytes::new("pairing endpoint hint bytes", endpoint_hint)?, + issued_at, + expires_at, + extensions, + }) + } +} + +/// Complete bounded QR/local-code ticket for one proposed device. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PairingTicket { + protocol_version: ProtocolVersion, + account_id: AccountId, + proposed_device: DeviceDescriptor, + proposed_device_id: DeviceId, + ephemeral_public_key: AgreementPublicKey, + proposed_endpoint: EndpointPublicKey, + endpoint_hint: BoundedBytes, + random_secret_commitment: Digest, + issued_at: Timestamp, + expires_at: Timestamp, + nonce: PairingNonce, + extensions: Extensions, +} + +impl PairingTicket { + /// Issue a ticket using fallible operating-system cryptographic entropy. + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] + pub fn issue( + request: PairingTicketRequest, + ) -> Result<(Self, PairingTicketSecrets), IdentityError> { + let mut random_secret = Zeroizing::new([0_u8; 32]); + let mut ephemeral_secret = Zeroizing::new([0_u8; 32]); + let mut nonce = [0_u8; 32]; + getrandom::fill(&mut random_secret[..]).map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(&mut ephemeral_secret[..]) + .map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(&mut nonce).map_err(|_| IdentityError::EntropyUnavailable)?; + Self::issue_with_material(request, random_secret, ephemeral_secret, nonce) + } + + /// Issue a deterministic ticket using an explicit cryptographic RNG. + pub fn issue_with_rng( + request: PairingTicketRequest, + rng: &mut impl TryCryptoRng, + ) -> Result<(Self, PairingTicketSecrets), IdentityError> { + let mut random_secret = Zeroizing::new([0_u8; 32]); + let mut ephemeral_secret = Zeroizing::new([0_u8; 32]); + let mut nonce = [0_u8; 32]; + rng.try_fill_bytes(&mut random_secret[..]) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(&mut ephemeral_secret[..]) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(&mut nonce) + .map_err(|_| IdentityError::EntropyUnavailable)?; + Self::issue_with_material(request, random_secret, ephemeral_secret, nonce) + } + + fn issue_with_material( + request: PairingTicketRequest, + random_secret: Zeroizing<[u8; 32]>, + ephemeral_secret: Zeroizing<[u8; 32]>, + nonce: [u8; 32], + ) -> Result<(Self, PairingTicketSecrets), IdentityError> { + if *random_secret == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "pairing random secret", + }); + } + if *ephemeral_secret == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "pairing ephemeral secret", + }); + } + let nonce = PairingNonce::new(nonce)?; + let pairing_secret = PairingRandomSecret(random_secret); + let ephemeral_secret = PairingEphemeralSecret(StaticSecret::from(*ephemeral_secret)); + let ephemeral_public_key = ephemeral_secret.public_key()?; + let proposed_device_id = request.proposed_device.id()?; + let proposed_endpoint = request.proposed_device.endpoint_key(); + let random_secret_commitment = pairing_secret.commitment(); + let ticket = Self::new( + request.account_id, + request.proposed_device, + proposed_device_id, + ephemeral_public_key, + proposed_endpoint, + request.endpoint_hint.into_vec(), + random_secret_commitment, + request.issued_at, + request.expires_at, + nonce, + request.extensions, + )?; + let ticket_id = ticket.ticket_id()?; + Ok(( + ticket, + PairingTicketSecrets { + ticket_id, + random_secret: pairing_secret, + ephemeral_secret, + }, + )) + } + + #[allow(clippy::too_many_arguments)] + fn new( + account_id: AccountId, + proposed_device: DeviceDescriptor, + proposed_device_id: DeviceId, + ephemeral_public_key: AgreementPublicKey, + proposed_endpoint: EndpointPublicKey, + endpoint_hint: Vec, + random_secret_commitment: Digest, + issued_at: Timestamp, + expires_at: Timestamp, + nonce: PairingNonce, + extensions: Extensions, + ) -> Result { + validate_ticket_times(issued_at, expires_at)?; + let derived_device_id = proposed_device.id()?; + if proposed_device_id != derived_device_id { + return Err(IdentityError::InvalidIdentifier { + resource: "pairing proposed device", + }); + } + if proposed_endpoint != proposed_device.endpoint_key() { + return Err(IdentityError::InvalidRelationship { + resource: "pairing proposed endpoint binding", + }); + } + extensions.validate_critical(&[])?; + let ticket = Self { + protocol_version: ProtocolVersion::V1, + account_id, + proposed_device, + proposed_device_id, + ephemeral_public_key, + proposed_endpoint, + endpoint_hint: BoundedBytes::new("pairing endpoint hint bytes", endpoint_hint)?, + random_secret_commitment, + issued_at, + expires_at, + nonce, + extensions, + }; + let encoded_len = encode_wire(&ticket)?.len(); + if encoded_len > MAX_PAIRING_TICKET_BYTES { + return Err(IdentityError::limit( + "pairing ticket bytes", + encoded_len, + MAX_PAIRING_TICKET_BYTES, + )); + } + Ok(ticket) + } + + /// Derive the identifier of the complete canonical ticket. + pub fn ticket_id(&self) -> Result { + Ok(PairingTicketId(derive_digest( + PAIRING_TICKET_ID_CONTEXT, + &self.to_canonical_bytes()?, + ))) + } + + /// Stable account being extended. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Complete proposed public device descriptor. + pub const fn proposed_device(&self) -> &DeviceDescriptor { + &self.proposed_device + } + + /// Exact identifier derived from the proposed descriptor. + pub const fn proposed_device_id(&self) -> DeviceId { + self.proposed_device_id + } + + /// Fresh X25519 key dedicated to this pairing ceremony. + pub const fn ephemeral_public_key(&self) -> AgreementPublicKey { + self.ephemeral_public_key + } + + /// Endpoint identity expected on the authenticated connection. + pub const fn proposed_endpoint(&self) -> EndpointPublicKey { + self.proposed_endpoint + } + + /// Opaque bounded discovery hint; never treated as transport authentication. + pub fn endpoint_hint(&self) -> &[u8] { + self.endpoint_hint.as_slice() + } + + /// Commitment to the separately retained fresh random secret. + pub const fn random_secret_commitment(&self) -> Digest { + self.random_secret_commitment + } + + /// Explicit ticket issue time. + pub const fn issued_at(&self) -> Timestamp { + self.issued_at + } + + /// Explicit ticket expiry time. + pub const fn expires_at(&self) -> Timestamp { + self.expires_at + } + + /// Durable one-time-use nonce. + pub const fn nonce(&self) -> PairingNonce { + self.nonce + } + + /// Signed forward-compatible fields. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl<'de> Deserialize<'de> for PairingTicket { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + proposed_device: DeviceDescriptor, + proposed_device_id: DeviceId, + ephemeral_public_key: AgreementPublicKey, + proposed_endpoint: EndpointPublicKey, + endpoint_hint: BoundedBytes, + random_secret_commitment: Digest, + issued_at: Timestamp, + expires_at: Timestamp, + nonce: PairingNonce, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::new( + wire.account_id, + wire.proposed_device, + wire.proposed_device_id, + wire.ephemeral_public_key, + wire.proposed_endpoint, + wire.endpoint_hint.into_vec(), + wire.random_secret_commitment, + wire.issued_at, + wire.expires_at, + wire.nonce, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for PairingTicket { + const RESOURCE: &'static str = "pairing ticket bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PAIRING_TICKET_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Ticket-side fresh secret material, retained only on the proposed device. +/// +/// This type is intentionally neither `Copy` nor `Clone` and redacts its debug output. +pub struct PairingTicketSecrets { + ticket_id: PairingTicketId, + random_secret: PairingRandomSecret, + ephemeral_secret: PairingEphemeralSecret, +} + +impl fmt::Debug for PairingTicketSecrets { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PairingTicketSecrets()") + } +} + +struct PairingRandomSecret(Zeroizing<[u8; 32]>); + +impl PairingRandomSecret { + fn commitment(&self) -> Digest { + derive_digest(PAIRING_SECRET_COMMITMENT_CONTEXT, &self.0[..]) + } +} + +struct PairingEphemeralSecret(StaticSecret); + +impl PairingEphemeralSecret { + fn public_key(&self) -> Result { + AgreementPublicKey::x25519(X25519PublicKey::from(&self.0).to_bytes()) + } +} + +#[cfg(test)] +mod tests; + +macro_rules! nonzero_bytes { + ($name:ident, $resource:literal, $debug:literal) => { + #[doc = $resource] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] + pub struct $name([u8; 32]); + + impl $name { + /// Validate exact nonzero bytes. + pub fn new(bytes: [u8; 32]) -> Result { + if bytes == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: $resource, + }); + } + Ok(Self(bytes)) + } + + /// Borrow the exact bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str($debug) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(<[u8; 32]>::deserialize(deserializer)?).map_err(de::Error::custom) + } + } + + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +nonzero_bytes!( + PairingSessionId, + "pairing session identifier", + "PairingSessionId()" +); +nonzero_bytes!( + PairingChallenge, + "pairing verifier challenge", + "PairingChallenge()" +); +/// Secret authenticated-transport exporter supplied by the effect boundary. +/// +/// The raw value is non-`Copy`, non-`Clone`, redacted, and erased on drop. Only its +/// domain-separated binding is retained in the protocol transcript. +#[cfg(any(test, feature = "net"))] +pub(crate) struct TransportExporterValue(Zeroizing<[u8; 32]>); + +#[cfg(any(test, feature = "net"))] +impl TransportExporterValue { + /// Take ownership of an exact nonzero exporter value at a trusted adapter boundary. + pub(crate) fn new(bytes: [u8; 32]) -> Result { + if bytes == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "authenticated transport exporter", + }); + } + Ok(Self(Zeroizing::new(bytes))) + } + + fn into_binding(self) -> Digest { + derive_digest(PAIRING_TRANSPORT_EXPORTER_CONTEXT, &self.0[..]) + } +} + +/// Complete authenticated facts returned by a crate-owned transport adapter. +#[cfg(any(test, feature = "net"))] +pub(crate) struct AuthenticatedTransportFacts { + /// Unique authenticated transport session. + pub(crate) session_id: PairingSessionId, + /// Locally authenticated controller endpoint. + pub(crate) controller_endpoint: EndpointPublicKey, + /// Remotely authenticated proposed-device endpoint. + pub(crate) proposed_endpoint: EndpointPublicKey, + /// Secret exporter extracted from the same authenticated connection. + pub(crate) exporter: TransportExporterValue, +} + +/// Sealed crate boundary that may attest authenticated pairing-transport facts. +/// +/// The optional network adapter and the private deterministic test adapter are the only intended +/// implementations. Public callers can carry a resulting binding but cannot implement this trait +/// or mint its facts. +#[cfg(any(test, feature = "net"))] +pub(crate) trait AuthenticatedTransportAdapter { + /// Consume the adapter and return facts obtained from one authenticated connection. + fn into_authenticated_transport_facts(self) -> AuthenticatedTransportFacts; +} + +#[cfg(any(test, feature = "net"))] +impl fmt::Debug for TransportExporterValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("TransportExporterValue()") + } +} + +/// Explicit evidence supplied by an authenticated Krikos transport adapter. +/// +/// Construction does not perform transport authentication. Task 6 transport code may construct +/// this capability only after authentication and exporter extraction succeed. Endpoint hints are +/// deliberately absent because they are discovery metadata, not authentication evidence. +/// +/// Public callers cannot mint raw transport evidence: +/// +/// ```compile_fail +/// use krikos_identity::TransportExporterValue; +/// +/// let _forged = TransportExporterValue::new([7_u8; 32]); +/// ``` +/// +/// Public callers also cannot invoke the authenticated adapter factory directly: +/// +/// ```compile_fail +/// use krikos_identity::AuthenticatedTransportBinding; +/// +/// let _forged = AuthenticatedTransportBinding::new(todo!(), todo!(), todo!(), todo!()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthenticatedTransportBinding { + session_id: PairingSessionId, + controller_endpoint: EndpointPublicKey, + proposed_endpoint: EndpointPublicKey, + exporter_binding: Digest, +} + +impl AuthenticatedTransportBinding { + /// Bind facts supplied by a crate-owned authenticated transport adapter. + #[cfg(any(test, feature = "net"))] + pub(crate) fn from_authenticated_adapter( + adapter: impl AuthenticatedTransportAdapter, + ) -> Result { + let facts = adapter.into_authenticated_transport_facts(); + Self::new( + facts.session_id, + facts.controller_endpoint, + facts.proposed_endpoint, + facts.exporter, + ) + } + + #[cfg(any(test, feature = "net"))] + fn new( + session_id: PairingSessionId, + controller_endpoint: EndpointPublicKey, + proposed_endpoint: EndpointPublicKey, + exporter: TransportExporterValue, + ) -> Result { + if controller_endpoint == proposed_endpoint { + return Err(IdentityError::InvalidRelationship { + resource: "pairing authenticated endpoint separation", + }); + } + Ok(Self { + session_id, + controller_endpoint, + proposed_endpoint, + exporter_binding: exporter.into_binding(), + }) + } + + /// Authenticated transport session identifier. + pub const fn session_id(self) -> PairingSessionId { + self.session_id + } + + /// Authenticated existing-controller endpoint. + pub const fn controller_endpoint(self) -> EndpointPublicKey { + self.controller_endpoint + } + + /// Authenticated proposed-device endpoint. + pub const fn proposed_endpoint(self) -> EndpointPublicKey { + self.proposed_endpoint + } + + /// Domain-separated binding of the channel exporter unique to this connection. + pub const fn exporter_binding(self) -> Digest { + self.exporter_binding + } +} + +/// Verifier-side X25519 secret dedicated to one authenticated pairing connection. +/// +/// This type is intentionally neither `Copy` nor `Clone` and zeroizes on drop. +pub struct ConnectionEphemeralSecret(StaticSecret); + +impl ConnectionEphemeralSecret { + /// Take ownership of exact X25519 secret-key material. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(StaticSecret::from(bytes)) + } + + /// Generate using fallible operating-system entropy. + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] + pub fn generate() -> Result { + let mut bytes = Zeroizing::new([0_u8; 32]); + getrandom::fill(&mut bytes[..]).map_err(|_| IdentityError::EntropyUnavailable)?; + Self::from_generated_material(bytes) + } + + /// Generate using an explicit deterministic cryptographic RNG. + pub fn generate_with_rng(rng: &mut impl TryCryptoRng) -> Result { + let mut bytes = Zeroizing::new([0_u8; 32]); + rng.try_fill_bytes(&mut bytes[..]) + .map_err(|_| IdentityError::EntropyUnavailable)?; + Self::from_generated_material(bytes) + } + + fn from_generated_material(bytes: Zeroizing<[u8; 32]>) -> Result { + if *bytes == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "pairing connection ephemeral secret", + }); + } + Ok(Self::from_bytes(*bytes)) + } + + /// Corresponding contributory X25519 public key. + pub fn public_key(&self) -> Result { + AgreementPublicKey::x25519(X25519PublicKey::from(&self.0).to_bytes()) + } + + fn diffie_hellman( + &self, + public_key: AgreementPublicKey, + ) -> Result { + let public_key = X25519PublicKey::from(*public_key.as_bytes()); + let shared_secret = self.0.diffie_hellman(&public_key); + validate_contributory(&shared_secret)?; + Ok(shared_secret) + } +} + +impl fmt::Debug for ConnectionEphemeralSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ConnectionEphemeralSecret()") + } +} + +/// Domain-separated identifier of a complete pairing transcript. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct PairingTranscriptId(Digest); + +impl PairingTranscriptId { + /// Borrow the tagged digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } +} + +impl CanonicalCodec for PairingTranscriptId { + const RESOURCE: &'static str = "pairing transcript identifier bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Complete immutable context signed and MACed by the proposed device. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PairingTranscript { + protocol_version: ProtocolVersion, + ticket_id: PairingTicketId, + random_secret_commitment: Digest, + account_id: AccountId, + proposed_device: DeviceDescriptor, + proposed_device_id: DeviceId, + controller_device: DeviceDescriptor, + controller_device_id: DeviceId, + proposed_endpoint: EndpointPublicKey, + controller_endpoint: EndpointPublicKey, + verifier_challenge: PairingChallenge, + session_id: PairingSessionId, + transport_exporter_binding: Digest, + pairing_ephemeral_public_key: AgreementPublicKey, + connection_ephemeral_public_key: AgreementPublicKey, +} + +impl PairingTranscript { + #[allow(clippy::too_many_arguments)] + fn new( + ticket: &PairingTicket, + controller_device: DeviceDescriptor, + transport: AuthenticatedTransportBinding, + verifier_challenge: PairingChallenge, + connection_ephemeral_public_key: AgreementPublicKey, + ) -> Result { + validate_pairing_public_key_separation( + &ticket.proposed_device, + &controller_device, + ticket.ephemeral_public_key, + connection_ephemeral_public_key, + )?; + let controller_device_id = controller_device.id()?; + if controller_device_id == ticket.proposed_device_id { + return Err(IdentityError::InvalidRelationship { + resource: "pairing controller and proposed device separation", + }); + } + if transport.controller_endpoint != controller_device.endpoint_key() + || transport.proposed_endpoint != ticket.proposed_endpoint + { + return Err(IdentityError::InvalidRelationship { + resource: "pairing authenticated transport endpoints", + }); + } + let transcript = Self { + protocol_version: ProtocolVersion::V1, + ticket_id: ticket.ticket_id()?, + random_secret_commitment: ticket.random_secret_commitment, + account_id: ticket.account_id, + proposed_device: ticket.proposed_device.clone(), + proposed_device_id: ticket.proposed_device_id, + controller_device, + controller_device_id, + proposed_endpoint: transport.proposed_endpoint, + controller_endpoint: transport.controller_endpoint, + verifier_challenge, + session_id: transport.session_id, + transport_exporter_binding: transport.exporter_binding, + pairing_ephemeral_public_key: ticket.ephemeral_public_key, + connection_ephemeral_public_key, + }; + let encoded_len = encode_wire(&transcript)?.len(); + if encoded_len > MAX_ACCOUNT_EVENT_BYTES { + return Err(IdentityError::limit( + "pairing transcript bytes", + encoded_len, + MAX_ACCOUNT_EVENT_BYTES, + )); + } + Ok(transcript) + } + + /// Derive the identifier of the complete transcript. + pub fn transcript_id(&self) -> Result { + Ok(PairingTranscriptId(derive_digest( + PAIRING_TRANSCRIPT_ID_CONTEXT, + &self.to_canonical_bytes()?, + ))) + } + + /// Exact domain-separated application-key possession message. + pub fn application_possession_signing_bytes(&self) -> Result, IdentityError> { + domain_message(APPLICATION_POSSESSION_DOMAIN, &self.to_canonical_bytes()?) + } + + /// Exact domain-separated endpoint-key possession message. + pub fn endpoint_possession_signing_bytes(&self) -> Result, IdentityError> { + domain_message(ENDPOINT_POSSESSION_DOMAIN, &self.to_canonical_bytes()?) + } + + /// Exact participant- and transcript-bound bytes signed after independent SAS confirmation. + pub fn confirmation_signing_bytes( + &self, + participant: ConfirmationParticipant, + observed_short_auth: ShortAuthString, + confirmed_at: Timestamp, + ) -> Result, IdentityError> { + pairing_confirmation_signing_bytes( + participant, + self.transcript_id()?, + observed_short_auth, + confirmed_at, + ) + } + + /// Construct one endpoint-signed participant confirmation for this exact transcript. + pub fn signed_confirmation( + &self, + participant: ConfirmationParticipant, + observed_short_auth: ShortAuthString, + confirmed_at: Timestamp, + endpoint_signature: ProtocolSignature, + ) -> Result { + Ok(PairingConfirmation { + participant, + transcript_id: self.transcript_id()?, + observed_short_auth, + confirmed_at, + endpoint_signature, + }) + } + + /// Account being extended. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Proposed complete public device descriptor. + pub const fn proposed_device(&self) -> &DeviceDescriptor { + &self.proposed_device + } + + /// Existing controller's complete public device descriptor. + pub const fn controller_device(&self) -> &DeviceDescriptor { + &self.controller_device + } + + /// Ticket identifier bound into this transcript. + pub const fn ticket_id(&self) -> PairingTicketId { + self.ticket_id + } + + /// Verifier challenge bound into this transcript. + pub const fn verifier_challenge(&self) -> PairingChallenge { + self.verifier_challenge + } + + /// Authenticated session bound into this transcript. + pub const fn session_id(&self) -> PairingSessionId { + self.session_id + } + + /// Domain-separated authenticated transport exporter binding in this transcript. + pub const fn transport_exporter_binding(&self) -> Digest { + self.transport_exporter_binding + } + + /// Proposed-device pairing-ephemeral public key committed by this transcript. + pub const fn pairing_ephemeral_public_key(&self) -> AgreementPublicKey { + self.pairing_ephemeral_public_key + } + + /// Controller connection-ephemeral public key committed by this transcript. + pub const fn connection_ephemeral_public_key(&self) -> AgreementPublicKey { + self.connection_ephemeral_public_key + } +} + +impl CanonicalCodec for PairingTranscript { + const RESOURCE: &'static str = "pairing transcript bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let transcript: Self = decode_wire(bytes)?; + if transcript.proposed_device.id()? != transcript.proposed_device_id + || transcript.controller_device.id()? != transcript.controller_device_id + || transcript.proposed_endpoint != transcript.proposed_device.endpoint_key() + || transcript.controller_endpoint != transcript.controller_device.endpoint_key() + || transcript.proposed_device_id == transcript.controller_device_id + { + return Err(IdentityError::InvalidRelationship { + resource: "pairing transcript device bindings", + }); + } + validate_pairing_public_key_separation( + &transcript.proposed_device, + &transcript.controller_device, + transcript.pairing_ephemeral_public_key, + transcript.connection_ephemeral_public_key, + )?; + Ok(transcript) + } +} + +fn validate_pairing_public_key_separation( + proposed_device: &DeviceDescriptor, + controller_device: &DeviceDescriptor, + pairing_ephemeral_public_key: AgreementPublicKey, + connection_ephemeral_public_key: AgreementPublicKey, +) -> Result<(), IdentityError> { + let proposed_application = proposed_device.application_signing_key(); + let proposed_agreement = proposed_device.agreement_key(); + let proposed_endpoint = proposed_device.endpoint_key().as_signing_key(); + let controller_application = controller_device.application_signing_key(); + let controller_agreement = controller_device.agreement_key(); + let controller_endpoint = controller_device.endpoint_key().as_signing_key(); + let public_keys = [ + proposed_application.as_bytes(), + proposed_agreement.as_bytes(), + proposed_endpoint.as_bytes(), + controller_application.as_bytes(), + controller_agreement.as_bytes(), + controller_endpoint.as_bytes(), + pairing_ephemeral_public_key.as_bytes(), + connection_ephemeral_public_key.as_bytes(), + ]; + let mut remaining = public_keys.as_slice(); + while let Some((public_key, tail)) = remaining.split_first() { + if tail.contains(public_key) { + return Err(IdentityError::InvalidRelationship { + resource: "pairing transcript public-key separation", + }); + } + remaining = tail; + } + Ok(()) +} + +/// Domain-separated identifier of a complete possession proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct PairingProofId(Digest); + +impl PairingProofId { + /// Borrow the tagged digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } +} + +impl CanonicalCodec for PairingProofId { + const RESOURCE: &'static str = "pairing possession proof identifier bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +struct PairingSecretReveal(Zeroizing<[u8; 32]>); + +impl PairingSecretReveal { + fn commitment(&self) -> Digest { + derive_digest(PAIRING_SECRET_COMMITMENT_CONTEXT, &self.0[..]) + } +} + +impl fmt::Debug for PairingSecretReveal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PairingSecretReveal()") + } +} + +impl Serialize for PairingSecretReveal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (*self.0).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PairingSecretReveal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self(Zeroizing::new(<[u8; 32]>::deserialize(deserializer)?))) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct PairingProofMac([u8; 32]); + +/// Complete proof of every proposed-device private key role and ticket secret. +/// +/// The revealed committed secret is transient protocol material. It and its containing proof are +/// intentionally not `Copy` or `Clone`, and the bytes are erased when dropped. +pub struct PairingPossessionProof { + protocol_version: ProtocolVersion, + transcript_id: PairingTranscriptId, + random_secret: PairingSecretReveal, + application_signature: ProtocolSignature, + endpoint_signature: ProtocolSignature, + agreement_mac: PairingProofMac, + pairing_ephemeral_mac: PairingProofMac, + extensions: Extensions, +} + +impl PairingPossessionProof { + /// Build all possession responses for one exact transcript. + pub fn create( + transcript: &PairingTranscript, + ticket_secrets: &PairingTicketSecrets, + agreement_secret: &AgreementSecretKey, + application_signature: ProtocolSignature, + endpoint_signature: ProtocolSignature, + ) -> Result { + if ticket_secrets.ticket_id != transcript.ticket_id + || ticket_secrets.random_secret.commitment() != transcript.random_secret_commitment + || ticket_secrets.ephemeral_secret.public_key()? + != transcript.pairing_ephemeral_public_key + { + return Err(IdentityError::InvalidRelationship { + resource: "pairing ticket secret proof subjects", + }); + } + if agreement_secret.public_key()? != transcript.proposed_device.agreement_key() { + return Err(IdentityError::InvalidRelationship { + resource: "pairing agreement secret proof subject", + }); + } + let agreement_shared = + agreement_secret.diffie_hellman(transcript.connection_ephemeral_public_key)?; + let ephemeral_shared = ticket_secrets + .ephemeral_secret + .diffie_hellman(transcript.connection_ephemeral_public_key)?; + let transcript_bytes = transcript.to_canonical_bytes()?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + transcript_id: transcript.transcript_id()?, + random_secret: PairingSecretReveal(Zeroizing::new(*ticket_secrets.random_secret.0)), + application_signature, + endpoint_signature, + agreement_mac: PairingProofMac(derive_possession_mac( + &agreement_shared, + AGREEMENT_PROOF_KEY_CONTEXT, + AGREEMENT_POSSESSION_DOMAIN, + transcript.proposed_device.agreement_key(), + transcript.connection_ephemeral_public_key, + &transcript_bytes, + )?), + pairing_ephemeral_mac: PairingProofMac(derive_possession_mac( + &ephemeral_shared, + EPHEMERAL_PROOF_KEY_CONTEXT, + EPHEMERAL_POSSESSION_DOMAIN, + transcript.pairing_ephemeral_public_key, + transcript.connection_ephemeral_public_key, + &transcript_bytes, + )?), + extensions: Extensions::default(), + }) + } + + /// Identifier of the complete proof, including all four role responses. + pub fn proof_id(&self) -> Result { + Ok(PairingProofId(derive_digest( + PAIRING_PROOF_ID_CONTEXT, + &self.to_canonical_bytes()?, + ))) + } + + /// Exact transcript identifier bound by all four possession responses. + pub const fn transcript_id(&self) -> PairingTranscriptId { + self.transcript_id + } + + /// Proposed-device application-key signature response. + pub const fn application_signature(&self) -> ProtocolSignature { + self.application_signature + } + + /// Proposed-device endpoint-key signature response. + pub const fn endpoint_signature(&self) -> ProtocolSignature { + self.endpoint_signature + } + + /// Proposed-device long-term agreement-key MAC response. + pub const fn agreement_mac(&self) -> &[u8; 32] { + &self.agreement_mac.0 + } + + /// Ticket-ephemeral agreement-key MAC response. + pub const fn pairing_ephemeral_mac(&self) -> &[u8; 32] { + &self.pairing_ephemeral_mac.0 + } +} + +impl fmt::Debug for PairingPossessionProof { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PairingPossessionProof") + .field("transcript_id", &self.transcript_id) + .field("responses", &"") + .finish_non_exhaustive() + } +} + +impl Serialize for PairingPossessionProof { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut tuple = serializer.serialize_tuple(8)?; + tuple.serialize_element(&self.protocol_version)?; + tuple.serialize_element(&self.transcript_id)?; + tuple.serialize_element(&self.random_secret)?; + tuple.serialize_element(&self.application_signature)?; + tuple.serialize_element(&self.endpoint_signature)?; + tuple.serialize_element(&self.agreement_mac)?; + tuple.serialize_element(&self.pairing_ephemeral_mac)?; + tuple.serialize_element(&self.extensions)?; + tuple.end() + } +} + +impl<'de> Deserialize<'de> for PairingPossessionProof { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let ( + protocol_version, + transcript_id, + random_secret, + application_signature, + endpoint_signature, + agreement_mac, + pairing_ephemeral_mac, + extensions, + ) = <( + ProtocolVersion, + PairingTranscriptId, + PairingSecretReveal, + ProtocolSignature, + ProtocolSignature, + PairingProofMac, + PairingProofMac, + Extensions, + )>::deserialize(deserializer)?; + if protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: protocol_version.get(), + })); + } + extensions + .validate_critical(&[]) + .map_err(de::Error::custom)?; + if !extensions.as_slice().is_empty() { + return Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "pairing possession proof extensions", + })); + } + Ok(Self { + protocol_version, + transcript_id, + random_secret, + application_signature, + endpoint_signature, + agreement_mac, + pairing_ephemeral_mac, + extensions, + }) + } +} + +impl CanonicalCodec for PairingPossessionProof { + const RESOURCE: &'static str = "pairing possession proof bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PAIRING_TICKET_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Six decimal digits derived from one complete transcript identifier. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct ShortAuthString([u8; 6]); + +impl ShortAuthString { + /// Validate six exact ASCII decimal digits. + pub fn new(digits: [u8; 6]) -> Result { + if !digits.iter().all(u8::is_ascii_digit) { + return Err(IdentityError::InvalidEncoding); + } + Ok(Self(digits)) + } + + fn derive(transcript_id: PairingTranscriptId) -> Result { + let bytes = transcript_id.0.as_bytes(); + let value = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 1_000_000; + let mut remainder = value; + let mut digits = [b'0'; 6]; + for index in (0..digits.len()).rev() { + let digit = + u8::try_from(remainder % 10).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "pairing short-auth digit", + })?; + digits[index] = b'0' + digit; + remainder /= 10; + } + Ok(Self(digits)) + } + + /// Exact six ASCII decimal digits. + pub const fn as_bytes(&self) -> &[u8; 6] { + &self.0 + } +} + +impl fmt::Debug for ShortAuthString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "ShortAuthString({self})") + } +} + +impl fmt::Display for ShortAuthString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for digit in self.0 { + fmt::Write::write_char(formatter, char::from(digit))?; + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for ShortAuthString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(<[u8; 6]>::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ShortAuthString { + const RESOURCE: &'static str = "pairing short-auth string bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Side providing one independently observed short-auth value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConfirmationParticipant { + /// Existing account controller. + Controller, + /// Proposed device. + ProposedDevice, +} + +/// Exact transcript-bound confirmation observation from one participant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PairingConfirmation { + participant: ConfirmationParticipant, + transcript_id: PairingTranscriptId, + observed_short_auth: ShortAuthString, + confirmed_at: Timestamp, + endpoint_signature: ProtocolSignature, +} + +/// Immutable context retained in the resulting non-authoritative proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct PairingConfirmationContext { + transcript_id: PairingTranscriptId, + short_auth: ShortAuthString, + controller_confirmed_at: Timestamp, + proposed_device_confirmed_at: Timestamp, +} + +impl PairingConfirmationContext { + /// Exact transcript confirmed on both devices. + pub const fn transcript_id(self) -> PairingTranscriptId { + self.transcript_id + } + + /// Transcript-derived value observed on both devices. + pub const fn short_auth(self) -> ShortAuthString { + self.short_auth + } + + fn validate(self) -> Result { + if self.short_auth != ShortAuthString::derive(self.transcript_id)? { + return Err(IdentityError::InvalidRelationship { + resource: "pairing confirmation short-auth transcript", + }); + } + Ok(self) + } +} + +impl<'de> Deserialize<'de> for PairingConfirmationContext { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (transcript_id, short_auth, controller_confirmed_at, proposed_device_confirmed_at) = + <(PairingTranscriptId, ShortAuthString, Timestamp, Timestamp)>::deserialize( + deserializer, + )?; + Self { + transcript_id, + short_auth, + controller_confirmed_at, + proposed_device_confirmed_at, + } + .validate() + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for PairingConfirmationContext { + const RESOURCE: &'static str = "pairing confirmation context bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Typestate for an accepted but not connected ticket. +#[derive(Debug)] +pub struct Issued; + +/// Typestate for an authenticated transport connection with a frozen transcript. +#[derive(Debug)] +pub struct Connected { + transcript: PairingTranscript, + connection_secret: ConnectionEphemeralSecret, +} + +/// Typestate after every proposed-device key role has been proven. +#[derive(Debug)] +pub struct Proven { + transcript: PairingTranscript, + transcript_id: PairingTranscriptId, + proof_id: PairingProofId, + short_auth: ShortAuthString, +} + +/// Typestate after exact two-sided transcript confirmation. +#[derive(Debug)] +pub struct Confirmed { + transcript: PairingTranscript, + proof_id: PairingProofId, + confirmation: PairingConfirmationContext, +} + +/// Terminal typestate after durable one-time consumption and proposal construction. +#[derive(Debug)] +pub struct Consumed { + proposal: DeviceAuthorizationProposal, +} + +/// Terminal typestate for an expired ticket. It exposes no proposal transition. +#[derive(Debug)] +pub struct Expired; + +/// Terminal typestate for an explicitly cancelled or mismatched ceremony. +#[derive(Debug)] +pub struct Cancelled; + +/// Pairing ceremony whose available transitions are controlled by `State`. +/// +/// A proposal cannot be extracted before durable consumption: +/// +/// ```compile_fail +/// use krikos_identity::{Confirmed, PairingCeremony}; +/// +/// fn proposal_too_early(confirmed: PairingCeremony) { +/// let _proposal = confirmed.into_proposal(); +/// } +/// ``` +#[derive(Debug)] +pub struct PairingCeremony { + ticket: PairingTicket, + state: State, +} + +/// Result of classifying one accepted ticket at an explicit time. +#[derive(Debug)] +pub enum PairingAdmission { + /// Ticket is within its validity interval. + Issued(PairingCeremony), + /// Ticket is already expired and terminal. + Expired(PairingCeremony), +} + +impl PairingCeremony { + /// Validate explicit acceptance time and durable replay state. + /// + /// Observing an expired ticket durably consumes it before returning [`PairingAdmission::Expired`]. + /// Callers must use the same durable store across process restarts. + pub fn accept( + ticket: PairingTicket, + store: &mut S, + now: Timestamp, + ) -> Result> { + let future_skew = u64::try_from(MAX_FUTURE_CLOCK_SKEW.as_millis()).map_err(|_| { + PairingConsumeError::Protocol(IdentityError::ArithmeticOverflow { + resource: "pairing future clock skew milliseconds", + }) + })?; + let maximum_issue_time = now + .checked_add(crate::DurationMillis::new(future_skew)) + .map_err(PairingConsumeError::Protocol)?; + if ticket.issued_at > maximum_issue_time { + return Err(PairingConsumeError::Protocol( + IdentityError::InvalidRelationship { + resource: "pairing ticket future issue time", + }, + )); + } + let ticket_id = ticket.ticket_id().map_err(PairingConsumeError::Protocol)?; + let key = PairingNonceKey { + account_id: ticket.account_id, + ticket_id, + nonce: ticket.nonce, + }; + if store.is_consumed(key).map_err(PairingConsumeError::Store)? { + return Err(PairingConsumeError::AlreadyConsumed); + } + if now > ticket.expires_at { + match store + .consume_atomically(key, ticket.expires_at) + .map_err(PairingConsumeError::Store)? + { + NonceConsumeResult::Consumed => {} + NonceConsumeResult::AlreadyConsumed => { + return Err(PairingConsumeError::AlreadyConsumed); + } + } + return Ok(PairingAdmission::Expired(PairingCeremony { + ticket, + state: Expired, + })); + } + Ok(PairingAdmission::Issued(Self { + ticket, + state: Issued, + })) + } + + /// Bind an explicit authenticated transport and verifier ephemeral secret. + pub fn connect( + self, + controller_device: DeviceDescriptor, + transport: AuthenticatedTransportBinding, + verifier_challenge: PairingChallenge, + connection_secret: ConnectionEphemeralSecret, + ) -> Result, IdentityError> { + let connection_public = connection_secret.public_key()?; + let transcript = PairingTranscript::new( + &self.ticket, + controller_device, + transport, + verifier_challenge, + connection_public, + )?; + Ok(PairingCeremony { + ticket: self.ticket, + state: Connected { + transcript, + connection_secret, + }, + }) + } + + /// Explicitly cancel before connection. + pub fn cancel(self) -> PairingCeremony { + self.into_cancelled() + } +} + +impl PairingCeremony { + /// Complete transcript supplied to the proposed device for proof construction. + pub const fn transcript(&self) -> &PairingTranscript { + &self.state.transcript + } + + /// Verify all four possession subjects and enter proven state. + pub fn verify_possession( + self, + proof: PairingPossessionProof, + ) -> Result, IdentityError> { + let expected_transcript_id = self.state.transcript.transcript_id()?; + if proof.transcript_id != expected_transcript_id { + return Err(IdentityError::InvalidRelationship { + resource: "pairing possession transcript", + }); + } + if proof.random_secret.commitment() != self.state.transcript.random_secret_commitment { + return Err(IdentityError::InvalidProof); + } + verify_signature( + self.state + .transcript + .proposed_device + .application_signing_key(), + &self + .state + .transcript + .application_possession_signing_bytes()?, + proof.application_signature, + )?; + verify_signature( + self.state + .transcript + .proposed_device + .endpoint_key() + .as_signing_key(), + &self.state.transcript.endpoint_possession_signing_bytes()?, + proof.endpoint_signature, + )?; + let transcript_bytes = self.state.transcript.to_canonical_bytes()?; + let agreement_shared = self + .state + .connection_secret + .diffie_hellman(self.state.transcript.proposed_device.agreement_key())?; + let expected_agreement_mac = derive_possession_mac( + &agreement_shared, + AGREEMENT_PROOF_KEY_CONTEXT, + AGREEMENT_POSSESSION_DOMAIN, + self.state.transcript.proposed_device.agreement_key(), + self.state.transcript.connection_ephemeral_public_key, + &transcript_bytes, + )?; + verify_mac(proof.agreement_mac.0, expected_agreement_mac)?; + let ephemeral_shared = self + .state + .connection_secret + .diffie_hellman(self.state.transcript.pairing_ephemeral_public_key)?; + let expected_ephemeral_mac = derive_possession_mac( + &ephemeral_shared, + EPHEMERAL_PROOF_KEY_CONTEXT, + EPHEMERAL_POSSESSION_DOMAIN, + self.state.transcript.pairing_ephemeral_public_key, + self.state.transcript.connection_ephemeral_public_key, + &transcript_bytes, + )?; + verify_mac(proof.pairing_ephemeral_mac.0, expected_ephemeral_mac)?; + let proof_id = proof.proof_id()?; + let short_auth = ShortAuthString::derive(expected_transcript_id)?; + Ok(PairingCeremony { + ticket: self.ticket, + state: Proven { + transcript: self.state.transcript, + transcript_id: expected_transcript_id, + proof_id, + short_auth, + }, + }) + } + + /// Explicitly cancel after connection. + pub fn cancel(self) -> PairingCeremony { + self.into_cancelled() + } +} + +/// Result of exact short-auth confirmation. +#[derive(Debug)] +pub enum PairingConfirmationOutcome { + /// Both participant observations match the transcript-derived value. + Confirmed(Box>), + /// A mismatch or invalid confirmation context terminally cancelled the ceremony. + Cancelled(Box>), +} + +impl PairingCeremony { + /// Transcript-derived short authentication string displayed on both devices. + pub const fn short_auth_string(&self) -> ShortAuthString { + self.state.short_auth + } + + /// Exact bytes one participant's endpoint key signs after independent SAS confirmation. + pub fn confirmation_signing_bytes( + &self, + participant: ConfirmationParticipant, + observed_short_auth: ShortAuthString, + confirmed_at: Timestamp, + ) -> Result, IdentityError> { + pairing_confirmation_signing_bytes( + participant, + self.state.transcript_id, + observed_short_auth, + confirmed_at, + ) + } + + /// Attach one participant's endpoint-key signature to an exact SAS observation. + pub const fn signed_confirmation( + &self, + participant: ConfirmationParticipant, + observed_short_auth: ShortAuthString, + confirmed_at: Timestamp, + endpoint_signature: ProtocolSignature, + ) -> PairingConfirmation { + PairingConfirmation { + participant, + transcript_id: self.state.transcript_id, + observed_short_auth, + confirmed_at, + endpoint_signature, + } + } + + /// Compare two exact side-specific confirmations and enter confirmed or cancelled state. + pub fn confirm( + self, + controller: PairingConfirmation, + proposed_device: PairingConfirmation, + ) -> PairingConfirmationOutcome { + let transcript_id = self.state.transcript_id; + let context_valid = controller.participant == ConfirmationParticipant::Controller + && proposed_device.participant == ConfirmationParticipant::ProposedDevice + && controller.transcript_id == transcript_id + && proposed_device.transcript_id == transcript_id + && controller.observed_short_auth == self.state.short_auth + && proposed_device.observed_short_auth == self.state.short_auth + && controller.confirmed_at >= self.ticket.issued_at + && proposed_device.confirmed_at >= self.ticket.issued_at + && controller.confirmed_at <= self.ticket.expires_at + && proposed_device.confirmed_at <= self.ticket.expires_at; + if !context_valid + || !confirmation_signature_is_valid( + &controller, + self.state + .transcript + .controller_device + .endpoint_key() + .as_signing_key(), + ) + || !confirmation_signature_is_valid( + &proposed_device, + self.state + .transcript + .proposed_device + .endpoint_key() + .as_signing_key(), + ) + { + return PairingConfirmationOutcome::Cancelled(Box::new(self.into_cancelled())); + } + PairingConfirmationOutcome::Confirmed(Box::new(PairingCeremony { + ticket: self.ticket, + state: Confirmed { + transcript: self.state.transcript, + proof_id: self.state.proof_id, + confirmation: PairingConfirmationContext { + transcript_id, + short_auth: self.state.short_auth, + controller_confirmed_at: controller.confirmed_at, + proposed_device_confirmed_at: proposed_device.confirmed_at, + }, + }, + })) + } + + /// Explicitly cancel after possession proof. + pub fn cancel(self) -> PairingCeremony { + self.into_cancelled() + } +} + +/// Immutable nonce-store key durably tombstoned before proposal construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PairingNonceKey { + account_id: AccountId, + ticket_id: PairingTicketId, + nonce: PairingNonce, +} + +impl PairingNonceKey { + /// Derive the complete durable tombstone key from one validated ticket. + pub fn for_ticket(ticket: &PairingTicket) -> Result { + Ok(Self { + account_id: ticket.account_id, + ticket_id: ticket.ticket_id()?, + nonce: ticket.nonce, + }) + } + + /// Account whose durable nonce namespace is used. + pub const fn account_id(self) -> AccountId { + self.account_id + } + + /// Exact consumed ticket identifier. + pub const fn ticket_id(self) -> PairingTicketId { + self.ticket_id + } + + /// Exact ticket nonce. + pub const fn nonce(self) -> PairingNonce { + self.nonce + } +} + +/// Outcome of one atomic durable nonce insertion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NonceConsumeResult { + /// This call durably inserted the one-time tombstone. + Consumed, + /// A durable tombstone already exists, including after process restart. + AlreadyConsumed, +} + +/// Durable one-time pairing nonce boundary. +/// +/// Implementations must atomically check and durably insert the complete record before returning +/// [`NonceConsumeResult::Consumed`]. A crash or error may not report success. Tombstones must +/// remain effective after restart; `expires_at` is compaction metadata and must never enable a +/// previously observed ticket again. +pub trait PairingNonceStore { + /// Storage-specific error. + type Error; + + /// Check whether one exact ticket key already has a durable tombstone. + fn is_consumed(&mut self, key: PairingNonceKey) -> Result; + + /// Atomically check and durably consume one exact ticket key. + /// + /// `expires_at` is explicit retention/compaction metadata, not part of the equality key. + fn consume_atomically( + &mut self, + key: PairingNonceKey, + expires_at: Timestamp, + ) -> Result; +} + +/// Failure before a proposal can be returned from durable consumption. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PairingConsumeError { + /// Atomic persistence failed; no proposal was constructed or returned. + Store(E), + /// Durable state proves the ticket was already consumed or expired. + AlreadyConsumed, + /// Proposal construction failed after safe nonce consumption. + Protocol(IdentityError), +} + +/// Successful durable consumption outcome. +#[derive(Debug)] +pub enum PairingConsumeOutcome { + /// Ticket was live and produced a non-authoritative authorization proposal. + Consumed(Box>), + /// Ticket was expired; the durable nonce tombstone was still written first. + Expired(Box>), +} + +impl PairingCeremony { + /// Durably consume first, then and only then construct an authorization proposal. + pub fn consume( + self, + store: &mut S, + now: Timestamp, + ) -> Result> { + let ticket_id = self + .ticket + .ticket_id() + .map_err(PairingConsumeError::Protocol)?; + let key = PairingNonceKey { + account_id: self.ticket.account_id, + ticket_id, + nonce: self.ticket.nonce, + }; + match store + .consume_atomically(key, self.ticket.expires_at) + .map_err(PairingConsumeError::Store)? + { + NonceConsumeResult::AlreadyConsumed => { + return Err(PairingConsumeError::AlreadyConsumed); + } + NonceConsumeResult::Consumed => {} + } + if now > self.ticket.expires_at { + return Ok(PairingConsumeOutcome::Expired(Box::new(PairingCeremony { + ticket: self.ticket, + state: Expired, + }))); + } + let transcript_id = self + .state + .transcript + .transcript_id() + .map_err(PairingConsumeError::Protocol)?; + let proposal = DeviceAuthorizationProposal::from_confirmed_pairing( + self.ticket.account_id, + self.ticket.proposed_device.clone(), + self.ticket.proposed_device_id, + ticket_id, + transcript_id, + self.state.proof_id, + self.state.confirmation, + ) + .map_err(PairingConsumeError::Protocol)?; + Ok(PairingConsumeOutcome::Consumed(Box::new(PairingCeremony { + ticket: self.ticket, + state: Consumed { proposal }, + }))) + } + + /// Explicitly cancel after confirmation and before durable consumption. + pub fn cancel(self) -> PairingCeremony { + self.into_cancelled() + } +} + +impl PairingCeremony { + /// Consume the terminal ceremony and return its non-authoritative proposal. + pub fn into_proposal(self) -> DeviceAuthorizationProposal { + self.state.proposal + } +} + +impl PairingCeremony { + /// Exact ticket carried through every typestate. + pub const fn ticket(&self) -> &PairingTicket { + &self.ticket + } + + fn into_cancelled(self) -> PairingCeremony { + PairingCeremony { + ticket: self.ticket, + state: Cancelled, + } + } +} + +fn domain_message(domain: &[u8], body: &[u8]) -> Result, IdentityError> { + let capacity = domain + .len() + .checked_add(1) + .and_then(|length| length.checked_add(body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "pairing domain-separated message bytes", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(domain); + message.push(0); + message.extend_from_slice(body); + Ok(message) +} + +fn pairing_confirmation_signing_bytes( + participant: ConfirmationParticipant, + transcript_id: PairingTranscriptId, + observed_short_auth: ShortAuthString, + confirmed_at: Timestamp, +) -> Result, IdentityError> { + let body = encode_wire(&( + ProtocolVersion::V1, + participant, + transcript_id, + observed_short_auth, + confirmed_at, + ))?; + domain_message(PAIRING_CONFIRMATION_DOMAIN, &body) +} + +fn confirmation_signature_is_valid( + confirmation: &PairingConfirmation, + public_key: crate::SigningPublicKey, +) -> bool { + let Ok(message) = pairing_confirmation_signing_bytes( + confirmation.participant, + confirmation.transcript_id, + confirmation.observed_short_auth, + confirmation.confirmed_at, + ) else { + return false; + }; + verify_signature(public_key, &message, confirmation.endpoint_signature).is_ok() +} + +fn derive_possession_mac( + shared_secret: &SharedSecret, + key_context: &'static str, + message_domain: &[u8], + subject_public_key: AgreementPublicKey, + connection_public_key: AgreementPublicKey, + transcript_bytes: &[u8], +) -> Result<[u8; 32], IdentityError> { + validate_contributory(shared_secret)?; + let mut material = Zeroizing::new([0_u8; 96]); + material[..32].copy_from_slice(shared_secret.as_bytes()); + material[32..64].copy_from_slice(subject_public_key.as_bytes()); + material[64..].copy_from_slice(connection_public_key.as_bytes()); + let key = Zeroizing::new(blake3::derive_key(key_context, &material[..])); + let message = domain_message(message_domain, transcript_bytes)?; + Ok(*blake3::keyed_hash(&key, &message).as_bytes()) +} + +fn verify_signature( + public_key: crate::SigningPublicKey, + message: &[u8], + signature: ProtocolSignature, +) -> Result<(), IdentityError> { + let public_key = Ed25519PublicKey::from_bytes(public_key.as_bytes()) + .map_err(|_| IdentityError::InvalidSignature)?; + let signature = Ed25519Signature::try_from(signature.as_bytes().as_slice()) + .map_err(|_| IdentityError::InvalidSignature)?; + public_key + .verify(message, &signature) + .map_err(|_| IdentityError::InvalidSignature) +} + +fn verify_mac(actual: [u8; 32], expected: [u8; 32]) -> Result<(), IdentityError> { + if blake3::Hash::from_bytes(actual) != blake3::Hash::from_bytes(expected) { + return Err(IdentityError::InvalidProof); + } + Ok(()) +} + +fn validate_contributory(shared_secret: &SharedSecret) -> Result<(), IdentityError> { + if !shared_secret.was_contributory() { + return Err(IdentityError::InvalidPublicKey { + kind: crate::AlgorithmKind::Agreement, + }); + } + Ok(()) +} + +impl PairingEphemeralSecret { + fn diffie_hellman( + &self, + public_key: AgreementPublicKey, + ) -> Result { + let public_key = X25519PublicKey::from(*public_key.as_bytes()); + let shared_secret = self.0.diffie_hellman(&public_key); + validate_contributory(&shared_secret)?; + Ok(shared_secret) + } +} diff --git a/protocols/krikos-identity/src/pairing/nonce_store.rs b/protocols/krikos-identity/src/pairing/nonce_store.rs new file mode 100644 index 00000000000..6998f128dc5 --- /dev/null +++ b/protocols/krikos-identity/src/pairing/nonce_store.rs @@ -0,0 +1,177 @@ +//! Production one-time pairing nonce stores. + +use std::collections::BTreeMap; +#[cfg(feature = "fs-store")] +use std::{path::Path, sync::Arc}; + +#[cfg(feature = "fs-store")] +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; + +use super::NonceConsumeResult; +use crate::{ + IdentityError, PairingNonceKey, PairingNonceStore, Timestamp, + limits::MAX_PAIRING_NONCE_TOMBSTONES, +}; + +#[cfg(feature = "fs-store")] +const PAIRING_NONCE_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-pairing-nonce-tombstones-v1"); + +/// In-memory production nonce store for ephemeral deployments and tests. +/// +/// Tombstones are never removed. At the visible capacity limit this store denies new pairing +/// consumption rather than weakening replay protection. +#[derive(Debug, Default)] +pub struct MemoryPairingNonceStore { + tombstones: BTreeMap, +} + +impl MemoryPairingNonceStore { + /// Construct an empty non-revivable tombstone store. + pub fn new() -> Self { + Self::default() + } + + /// Number of durably modeled consumed ticket tombstones. + pub fn len(&self) -> usize { + self.tombstones.len() + } + + /// Whether no ticket has been consumed. + pub fn is_empty(&self) -> bool { + self.tombstones.is_empty() + } +} + +impl PairingNonceStore for MemoryPairingNonceStore { + type Error = IdentityError; + + fn is_consumed(&mut self, key: PairingNonceKey) -> Result { + Ok(self.tombstones.contains_key(&key)) + } + + fn consume_atomically( + &mut self, + key: PairingNonceKey, + expires_at: Timestamp, + ) -> Result { + if self.tombstones.contains_key(&key) { + return Ok(NonceConsumeResult::AlreadyConsumed); + } + if self.tombstones.len() >= MAX_PAIRING_NONCE_TOMBSTONES { + return Err(IdentityError::limit( + "pairing nonce tombstones", + self.tombstones.len().saturating_add(1), + MAX_PAIRING_NONCE_TOMBSTONES, + )); + } + self.tombstones.insert(key, expires_at); + Ok(NonceConsumeResult::Consumed) + } +} + +/// Redb-backed pairing nonce store whose committed tombstones never become valid tickets again. +#[cfg(feature = "fs-store")] +#[derive(Debug, Clone)] +pub struct RedbPairingNonceStore { + database: Arc, +} + +#[cfg(feature = "fs-store")] +impl RedbPairingNonceStore { + /// Open or create a crash-safe pairing tombstone store. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + crate::redb_guard::validate_existing_redb_file(path)?; + let database = Database::create(path).map_err(|_| IdentityError::StorageCorruption)?; + let write = database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let _ = write + .open_table(PAIRING_NONCE_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(Self { + database: Arc::new(database), + }) + } + + fn key(key: PairingNonceKey) -> Result, IdentityError> { + crate::codec::encode_wire(&(key.account_id, key.ticket_id, key.nonce)) + } +} + +#[cfg(feature = "fs-store")] +impl PairingNonceStore for RedbPairingNonceStore { + type Error = IdentityError; + + fn is_consumed(&mut self, key: PairingNonceKey) -> Result { + let key = Self::key(key)?; + let read = self + .database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let table = read + .open_table(PAIRING_NONCE_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + match table + .get(key.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + { + None => Ok(false), + Some(value) if value.value().len() == 8 => Ok(true), + Some(_) => Err(IdentityError::StorageCorruption), + } + } + + fn consume_atomically( + &mut self, + key: PairingNonceKey, + expires_at: Timestamp, + ) -> Result { + let key = Self::key(key)?; + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let result = { + let mut table = write + .open_table(PAIRING_NONCE_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let existing = table + .get(key.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| value.value().len()); + match existing { + Some(8) => Ok(NonceConsumeResult::AlreadyConsumed), + Some(_) => Err(IdentityError::StorageCorruption), + None => { + let count = table + .iter() + .map_err(|_| IdentityError::StorageCorruption)? + .count(); + if count >= MAX_PAIRING_NONCE_TOMBSTONES { + return Err(IdentityError::limit( + "pairing nonce tombstones", + count.saturating_add(1), + MAX_PAIRING_NONCE_TOMBSTONES, + )); + } + table + .insert( + key.as_slice(), + expires_at.as_unix_millis().to_be_bytes().as_slice(), + ) + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(NonceConsumeResult::Consumed) + } + } + }?; + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(result) + } +} diff --git a/protocols/krikos-identity/src/pairing/tests.rs b/protocols/krikos-identity/src/pairing/tests.rs new file mode 100644 index 00000000000..e5d6a64384b --- /dev/null +++ b/protocols/krikos-identity/src/pairing/tests.rs @@ -0,0 +1,1335 @@ +use std::{convert::Infallible, fmt}; + +use krikos_base::SecretKey; +use rand_core::{TryCryptoRng, TryRng}; + +use super::{AuthenticatedTransportAdapter, AuthenticatedTransportFacts, TransportExporterValue}; +use crate::{ + AccountId, AgreementSecretKey, AuthenticatedTransportBinding, CanonicalWire, + ConfirmationParticipant, ConnectionEphemeralSecret, DeviceAuthorizationProposal, + DeviceDescriptor, DevicePresenceChallenge, Digest, EndpointPublicKey, Extensions, + HashAlgorithm, IdentityError, MemoryPairingNonceStore, NonceConsumeResult, PairingAdmission, + PairingCeremony, PairingChallenge, PairingConfirmationOutcome, PairingConsumeError, + PairingConsumeOutcome, PairingNonceKey, PairingNonceStore, PairingPossessionProof, + PairingSessionId, PairingTicket, PairingTicketRequest, PairingTicketSecrets, PresenceProof, + ProtocolSignature, ProtocolVersion, SigningPublicKey, Timestamp, +}; + +struct TestAuthenticatedTransportAdapter { + facts: AuthenticatedTransportFacts, +} + +impl TestAuthenticatedTransportAdapter { + fn new( + session_id: PairingSessionId, + controller_endpoint: EndpointPublicKey, + proposed_endpoint: EndpointPublicKey, + exporter_bytes: [u8; 32], + ) -> Result { + Ok(Self { + facts: AuthenticatedTransportFacts { + session_id, + controller_endpoint, + proposed_endpoint, + exporter: TransportExporterValue::new(exporter_bytes)?, + }, + }) + } + + fn into_binding(self) -> Result { + AuthenticatedTransportBinding::from_authenticated_adapter(self) + } +} + +impl AuthenticatedTransportAdapter for TestAuthenticatedTransportAdapter { + fn into_authenticated_transport_facts(self) -> AuthenticatedTransportFacts { + self.facts + } +} + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +#[derive(Debug)] +struct InjectedEntropyFailure; + +impl fmt::Display for InjectedEntropyFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("injected entropy failure") + } +} + +impl std::error::Error for InjectedEntropyFailure {} + +struct FailingRng; + +impl TryRng for FailingRng { + type Error = InjectedEntropyFailure; + + fn try_next_u32(&mut self) -> Result { + Err(InjectedEntropyFailure) + } + + fn try_next_u64(&mut self) -> Result { + Err(InjectedEntropyFailure) + } + + fn try_fill_bytes(&mut self, _destination: &mut [u8]) -> Result<(), Self::Error> { + Err(InjectedEntropyFailure) + } +} + +impl TryCryptoRng for FailingRng {} + +struct ScriptedFillRng { + fills: [u8; 3], + next_fill: usize, +} + +impl ScriptedFillRng { + const fn new(fills: [u8; 3]) -> Self { + Self { + fills, + next_fill: 0, + } + } +} + +impl TryRng for ScriptedFillRng { + type Error = InjectedEntropyFailure; + + fn try_next_u32(&mut self) -> Result { + Err(InjectedEntropyFailure) + } + + fn try_next_u64(&mut self) -> Result { + Err(InjectedEntropyFailure) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + let seed = self + .fills + .get(self.next_fill) + .copied() + .ok_or(InjectedEntropyFailure)?; + self.next_fill = self + .next_fill + .checked_add(1) + .ok_or(InjectedEntropyFailure)?; + destination.fill(seed); + Ok(()) + } +} + +impl TryCryptoRng for ScriptedFillRng {} + +fn account_id(seed: u8) -> AccountId { + let digest = Digest::new(HashAlgorithm::Blake3_256, [seed; 32]); + AccountId::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn descriptor( + application_secret: &SecretKey, + agreement_secret: &AgreementSecretKey, + endpoint_secret: &SecretKey, +) -> DeviceDescriptor { + DeviceDescriptor::new( + SigningPublicKey::ed25519(*application_secret.public().as_bytes()).unwrap(), + agreement_secret.public_key().unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*endpoint_secret.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap() +} + +struct DeviceSecrets { + application: SecretKey, + agreement: AgreementSecretKey, + endpoint: SecretKey, +} + +impl DeviceSecrets { + fn new(seed: u8) -> Self { + Self { + application: SecretKey::from_bytes(&[seed; 32]), + agreement: AgreementSecretKey::from_bytes([seed.checked_add(1).unwrap(); 32]), + endpoint: SecretKey::from_bytes(&[seed.checked_add(2).unwrap(); 32]), + } + } + + fn descriptor(&self) -> DeviceDescriptor { + descriptor(&self.application, &self.agreement, &self.endpoint) + } +} + +struct TicketFixture { + ticket: PairingTicket, + ticket_secrets: PairingTicketSecrets, + proposed_secrets: DeviceSecrets, + controller_secrets: DeviceSecrets, +} + +fn ticket_fixture() -> TicketFixture { + ticket_fixture_with_rng(0x5a) +} + +fn ticket_fixture_with_rng(random_seed: u8) -> TicketFixture { + let proposed_secrets = DeviceSecrets::new(10); + let controller_secrets = DeviceSecrets::new(20); + let request = PairingTicketRequest::new( + account_id(1), + proposed_secrets.descriptor(), + b"relay.example.invalid".to_vec(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(601_000), + Extensions::default(), + ) + .unwrap(); + let (ticket, ticket_secrets) = + PairingTicket::issue_with_rng(request, &mut RepeatingRng(random_seed)).unwrap(); + TicketFixture { + ticket, + ticket_secrets, + proposed_secrets, + controller_secrets, + } +} + +#[allow(clippy::too_many_arguments)] +fn ticket_wire( + ticket: &PairingTicket, + account_id: AccountId, + proposed_device_id: crate::DeviceId, + proposed_endpoint: EndpointPublicKey, + commitment: Digest, +) -> Vec { + postcard::to_stdvec(&( + ProtocolVersion::V1, + account_id, + ticket.proposed_device().clone(), + proposed_device_id, + ticket.ephemeral_public_key(), + proposed_endpoint, + ticket.endpoint_hint().to_vec(), + commitment, + ticket.issued_at(), + ticket.expires_at(), + ticket.nonce(), + ticket.extensions().clone(), + )) + .unwrap() +} + +#[test] +fn ticket_is_bounded_canonical_and_binds_the_derived_device() { + let secrets = DeviceSecrets::new(2); + let descriptor = secrets.descriptor(); + let request = PairingTicketRequest::new( + account_id(1), + descriptor.clone(), + b"relay.example.invalid".to_vec(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(601_000), + Extensions::default(), + ) + .unwrap(); + let (ticket, _secrets) = + PairingTicket::issue_with_rng(request, &mut RepeatingRng(0x5a)).unwrap(); + + assert_eq!(ticket.proposed_device_id(), descriptor.id().unwrap()); + assert_eq!(ticket.proposed_endpoint(), descriptor.endpoint_key()); + let encoded = ticket.to_canonical_bytes().unwrap(); + assert!(encoded.len() <= crate::limits::MAX_PAIRING_TICKET_BYTES); + assert_eq!( + PairingTicket::from_canonical_bytes(&encoded).unwrap(), + ticket + ); +} + +#[test] +fn ticket_rejects_bounds_bad_time_entropy_and_low_order_ephemeral_keys() { + let secrets = DeviceSecrets::new(2); + assert!(matches!( + PairingTicketRequest::new( + account_id(1), + secrets.descriptor(), + vec![0; crate::MAX_PAIRING_ENDPOINT_HINT_BYTES + 1], + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(2_000), + Extensions::default(), + ), + Err(IdentityError::LimitExceeded { .. }) + )); + assert!( + PairingTicketRequest::new( + account_id(1), + secrets.descriptor(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(601_001), + Extensions::default(), + ) + .is_err() + ); + assert!( + PairingTicketRequest::new( + account_id(1), + secrets.descriptor(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .is_err() + ); + + let request = PairingTicketRequest::new( + account_id(1), + secrets.descriptor(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(2_000), + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + PairingTicket::issue_with_rng(request, &mut RepeatingRng(0)), + Err(IdentityError::ZeroValue { .. }) + )); + + let request = PairingTicketRequest::new( + account_id(1), + secrets.descriptor(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(2_000), + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + PairingTicket::issue_with_rng(request, &mut FailingRng), + Err(IdentityError::EntropyUnavailable) + )); + assert!(matches!( + ConnectionEphemeralSecret::generate_with_rng(&mut FailingRng), + Err(IdentityError::EntropyUnavailable) + )); + + let fixture = ticket_fixture(); + let mut encoded = fixture.ticket.to_canonical_bytes().unwrap(); + let public_key = fixture.ticket.ephemeral_public_key(); + let start = encoded + .windows(32) + .position(|window| window == public_key.as_bytes()) + .expect("ticket encoding must contain its exact ephemeral public key"); + encoded[start..start + 32].fill(0); + let result = PairingTicket::from_canonical_bytes(&encoded); + assert!(result.is_err(), "unexpected low-order result: {result:?}"); +} + +#[test] +fn ticket_generation_rejects_all_zero_ephemeral_source_material() { + let secrets = DeviceSecrets::new(2); + let request = PairingTicketRequest::new( + account_id(1), + secrets.descriptor(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(2_000), + Extensions::default(), + ) + .unwrap(); + let mut rng = ScriptedFillRng::new([1, 0, 2]); + + assert!(matches!( + PairingTicket::issue_with_rng(request, &mut rng), + Err(IdentityError::ZeroValue { .. }) + )); +} + +#[test] +fn connection_generation_rejects_all_zero_source_material_before_clamping() { + let mut rng = ScriptedFillRng::new([0, 1, 2]); + assert!(matches!( + ConnectionEphemeralSecret::generate_with_rng(&mut rng), + Err(IdentityError::ZeroValue { .. }) + )); + + assert!( + ConnectionEphemeralSecret::from_bytes([0; 32]) + .public_key() + .is_ok(), + "the explicit deterministic compatibility constructor remains infallible" + ); +} + +#[test] +fn ticket_wire_rejects_wrong_derived_device_and_endpoint() { + let fixture = ticket_fixture(); + let wrong_device = DeviceSecrets::new(80).descriptor(); + let wrong_id = wrong_device.id().unwrap(); + let wrong_endpoint = wrong_device.endpoint_key(); + let wrong_id_wire = ticket_wire( + &fixture.ticket, + fixture.ticket.account_id(), + wrong_id, + fixture.ticket.proposed_endpoint(), + fixture.ticket.random_secret_commitment(), + ); + let result = PairingTicket::from_canonical_bytes(&wrong_id_wire); + assert!(result.is_err(), "unexpected wrong-id result: {result:?}"); + + let wrong_endpoint_wire = ticket_wire( + &fixture.ticket, + fixture.ticket.account_id(), + fixture.ticket.proposed_device_id(), + wrong_endpoint, + fixture.ticket.random_secret_commitment(), + ); + let result = PairingTicket::from_canonical_bytes(&wrong_endpoint_wire); + assert!( + result.is_err(), + "unexpected wrong-endpoint result: {result:?}" + ); +} + +#[derive(Debug, Default, Clone)] +struct DurableNonceStore { + consumed: Vec, + fail_next: bool, +} + +impl PairingNonceStore for DurableNonceStore { + type Error = &'static str; + + fn is_consumed(&mut self, key: PairingNonceKey) -> Result { + Ok(self.consumed.contains(&key)) + } + + fn consume_atomically( + &mut self, + key: PairingNonceKey, + _expires_at: Timestamp, + ) -> Result { + if self.fail_next { + self.fail_next = false; + return Err("injected durable write failure"); + } + if self.consumed.contains(&key) { + return Ok(NonceConsumeResult::AlreadyConsumed); + } + self.consumed.push(key); + Ok(NonceConsumeResult::Consumed) + } +} + +fn accept_ticket( + ticket: PairingTicket, + now: Timestamp, +) -> Result> { + PairingCeremony::accept(ticket, &mut DurableNonceStore::default(), now) +} + +fn connect_fixture( + fixture: &TicketFixture, + exporter_seed: u8, +) -> crate::PairingCeremony { + let admission = + accept_ticket(fixture.ticket.clone(), Timestamp::from_unix_millis(2_000)).unwrap(); + let PairingAdmission::Issued(issued) = admission else { + panic!("fresh ticket must be issued"); + }; + let controller = fixture.controller_secrets.descriptor(); + let binding = TestAuthenticatedTransportAdapter::new( + PairingSessionId::new([0x31; 32]).unwrap(), + controller.endpoint_key(), + fixture.ticket.proposed_endpoint(), + [exporter_seed; 32], + ) + .unwrap() + .into_binding() + .unwrap(); + issued + .connect( + controller, + binding, + PairingChallenge::new([0x41; 32]).unwrap(), + ConnectionEphemeralSecret::from_bytes([0x51; 32]), + ) + .unwrap() +} + +fn connect_ticket( + fixture: &TicketFixture, + ticket: PairingTicket, + session_seed: u8, + challenge_seed: u8, + exporter_seed: u8, +) -> Result, IdentityError> { + let PairingAdmission::Issued(issued) = + accept_ticket(ticket.clone(), Timestamp::from_unix_millis(2_000)) + .map_err(|_| IdentityError::StaleEvidence)? + else { + return Err(IdentityError::StaleEvidence); + }; + let controller = fixture.controller_secrets.descriptor(); + let binding = TestAuthenticatedTransportAdapter::new( + PairingSessionId::new([session_seed; 32])?, + controller.endpoint_key(), + ticket.proposed_endpoint(), + [exporter_seed; 32], + )? + .into_binding()?; + issued.connect( + controller, + binding, + PairingChallenge::new([challenge_seed; 32])?, + ConnectionEphemeralSecret::from_bytes([0x51; 32]), + ) +} + +fn connect_with_controller_and_connection( + fixture: &TicketFixture, + controller: DeviceDescriptor, + connection_secret_seed: u8, +) -> Result, IdentityError> { + let PairingAdmission::Issued(issued) = + accept_ticket(fixture.ticket.clone(), Timestamp::from_unix_millis(2_000)) + .map_err(|_| IdentityError::StaleEvidence)? + else { + return Err(IdentityError::StaleEvidence); + }; + let binding = TestAuthenticatedTransportAdapter::new( + PairingSessionId::new([0x31; 32])?, + controller.endpoint_key(), + fixture.ticket.proposed_endpoint(), + [0x61; 32], + )? + .into_binding()?; + issued.connect( + controller, + binding, + PairingChallenge::new([0x41; 32])?, + ConnectionEphemeralSecret::from_bytes([connection_secret_seed; 32]), + ) +} + +fn proof_for( + fixture: &TicketFixture, + connected: &crate::PairingCeremony, +) -> PairingPossessionProof { + let transcript = connected.transcript(); + let application_signature = fixture + .proposed_secrets + .application + .sign(&transcript.application_possession_signing_bytes().unwrap()); + let endpoint_signature = fixture + .proposed_secrets + .endpoint + .sign(&transcript.endpoint_possession_signing_bytes().unwrap()); + PairingPossessionProof::create( + transcript, + &fixture.ticket_secrets, + &fixture.proposed_secrets.agreement, + ProtocolSignature::ed25519(application_signature.to_bytes()), + ProtocolSignature::ed25519(endpoint_signature.to_bytes()), + ) + .unwrap() +} + +fn signed_confirmation( + proven: &crate::PairingCeremony, + endpoint_secret: &SecretKey, + participant: ConfirmationParticipant, + observed_short_auth: crate::ShortAuthString, + confirmed_at: Timestamp, +) -> crate::PairingConfirmation { + let signing_bytes = proven + .confirmation_signing_bytes(participant, observed_short_auth, confirmed_at) + .unwrap(); + let signature = endpoint_secret.sign(&signing_bytes); + proven.signed_confirmation( + participant, + observed_short_auth, + confirmed_at, + ProtocolSignature::ed25519(signature.to_bytes()), + ) +} + +fn confirmed_fixture(fixture: &TicketFixture) -> crate::PairingCeremony { + let connected = connect_fixture(fixture, 0x61); + let proof = proof_for(fixture, &connected); + let proven = connected.verify_possession(proof).unwrap(); + let short_auth = proven.short_auth_string(); + let controller = signed_confirmation( + &proven, + &fixture.controller_secrets.endpoint, + ConfirmationParticipant::Controller, + short_auth, + Timestamp::from_unix_millis(3_000), + ); + let proposed = signed_confirmation( + &proven, + &fixture.proposed_secrets.endpoint, + ConfirmationParticipant::ProposedDevice, + short_auth, + Timestamp::from_unix_millis(3_001), + ); + let PairingConfirmationOutcome::Confirmed(confirmed) = proven.confirm(controller, proposed) + else { + panic!("matching exact transcript values must confirm"); + }; + *confirmed +} + +fn consumed_proposal(outcome: PairingConsumeOutcome) -> &'static DeviceAuthorizationProposal { + let PairingConsumeOutcome::Consumed(consumed) = outcome else { + panic!("fresh confirmed ceremony must be consumed"); + }; + Box::leak(Box::new((*consumed).into_proposal())) +} + +#[test] +fn complete_transcript_proves_every_key_role_and_consumes_before_proposal() { + let fixture = ticket_fixture(); + let confirmed = confirmed_fixture(&fixture); + let mut store = DurableNonceStore::default(); + let proposal = consumed_proposal( + confirmed + .consume(&mut store, Timestamp::from_unix_millis(4_000)) + .unwrap(), + ); + + assert_eq!(proposal.account_id(), fixture.ticket.account_id()); + assert_eq!( + proposal.proposed_device_id(), + fixture.ticket.proposed_device_id() + ); + assert_eq!(store.consumed.len(), 1); + assert!(proposal.proposal_id().is_ok()); +} + +#[test] +fn possession_proof_is_canonical_and_ticket_id_prevents_account_tampering() { + let fixture = ticket_fixture(); + let connected = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &connected); + let encoded = proof.to_canonical_bytes().unwrap(); + let decoded = PairingPossessionProof::from_canonical_bytes(&encoded).unwrap(); + assert_eq!(decoded.to_canonical_bytes().unwrap(), encoded); + assert!(connected.verify_possession(decoded).is_ok()); + + let tampered_wire = ticket_wire( + &fixture.ticket, + account_id(90), + fixture.ticket.proposed_device_id(), + fixture.ticket.proposed_endpoint(), + fixture.ticket.random_secret_commitment(), + ); + let tampered_ticket = PairingTicket::from_canonical_bytes(&tampered_wire).unwrap(); + let connected = connect_ticket(&fixture, tampered_ticket, 0x31, 0x41, 0x61).unwrap(); + let transcript = connected.transcript(); + let application = fixture + .proposed_secrets + .application + .sign(&transcript.application_possession_signing_bytes().unwrap()); + let endpoint = fixture + .proposed_secrets + .endpoint + .sign(&transcript.endpoint_possession_signing_bytes().unwrap()); + assert!(matches!( + PairingPossessionProof::create( + transcript, + &fixture.ticket_secrets, + &fixture.proposed_secrets.agreement, + ProtocolSignature::ed25519(application.to_bytes()), + ProtocolSignature::ed25519(endpoint.to_bytes()), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn exporter_substitution_and_each_forged_signature_are_rejected() { + let fixture = ticket_fixture(); + let original = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &original); + let substituted = connect_fixture(&fixture, 0x62); + assert!(matches!( + substituted.verify_possession(proof), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let connected = connect_fixture(&fixture, 0x61); + let transcript = connected.transcript(); + let wrong = SecretKey::from_bytes(&[0x70; 32]); + let wrong_application = wrong.sign(&transcript.application_possession_signing_bytes().unwrap()); + let endpoint = fixture + .proposed_secrets + .endpoint + .sign(&transcript.endpoint_possession_signing_bytes().unwrap()); + let proof = PairingPossessionProof::create( + transcript, + &fixture.ticket_secrets, + &fixture.proposed_secrets.agreement, + ProtocolSignature::ed25519(wrong_application.to_bytes()), + ProtocolSignature::ed25519(endpoint.to_bytes()), + ) + .unwrap(); + assert_eq!( + connected.verify_possession(proof).unwrap_err(), + IdentityError::InvalidSignature + ); + + let connected = connect_fixture(&fixture, 0x61); + let transcript = connected.transcript(); + let application = fixture + .proposed_secrets + .application + .sign(&transcript.application_possession_signing_bytes().unwrap()); + let wrong_endpoint = wrong.sign(&transcript.endpoint_possession_signing_bytes().unwrap()); + let proof = PairingPossessionProof::create( + transcript, + &fixture.ticket_secrets, + &fixture.proposed_secrets.agreement, + ProtocolSignature::ed25519(application.to_bytes()), + ProtocolSignature::ed25519(wrong_endpoint.to_bytes()), + ) + .unwrap(); + assert_eq!( + connected.verify_possession(proof).unwrap_err(), + IdentityError::InvalidSignature + ); +} + +#[test] +fn challenge_session_controller_descriptor_and_endpoint_substitution_fail() { + let fixture = ticket_fixture(); + let original = connect_ticket(&fixture, fixture.ticket.clone(), 0x31, 0x41, 0x61).unwrap(); + let original_proof = proof_for(&fixture, &original); + let session_substituted = + connect_ticket(&fixture, fixture.ticket.clone(), 0x32, 0x41, 0x61).unwrap(); + assert!(matches!( + session_substituted.verify_possession(original_proof), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let original = connect_fixture(&fixture, 0x61); + let original_proof = proof_for(&fixture, &original); + let challenge_substituted = + connect_ticket(&fixture, fixture.ticket.clone(), 0x31, 0x42, 0x61).unwrap(); + assert!(matches!( + challenge_substituted.verify_possession(original_proof), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let PairingAdmission::Issued(issued) = + accept_ticket(fixture.ticket.clone(), Timestamp::from_unix_millis(2_000)).unwrap() + else { + panic!("fixture ticket must be live"); + }; + let controller = fixture.controller_secrets.descriptor(); + let wrong_endpoint = DeviceSecrets::new(90).descriptor().endpoint_key(); + let binding = TestAuthenticatedTransportAdapter::new( + PairingSessionId::new([0x31; 32]).unwrap(), + controller.endpoint_key(), + wrong_endpoint, + [0x61; 32], + ) + .unwrap() + .into_binding() + .unwrap(); + assert!(matches!( + issued.connect( + controller, + binding, + PairingChallenge::new([0x41; 32]).unwrap(), + ConnectionEphemeralSecret::from_bytes([0x51; 32]), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn descriptor_connection_ephemeral_and_commitment_substitution_fail() { + let fixture = ticket_fixture(); + let original = connect_fixture(&fixture, 0x61); + let original_proof = proof_for(&fixture, &original); + let descriptor_substituted = + connect_with_controller_and_connection(&fixture, DeviceSecrets::new(30).descriptor(), 0x51) + .unwrap(); + assert!(matches!( + descriptor_substituted.verify_possession(original_proof), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let original = connect_fixture(&fixture, 0x61); + let original_proof = proof_for(&fixture, &original); + let connection_ephemeral_substituted = connect_with_controller_and_connection( + &fixture, + fixture.controller_secrets.descriptor(), + 0x52, + ) + .unwrap(); + assert!(matches!( + connection_ephemeral_substituted.verify_possession(original_proof), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let original = connect_fixture(&fixture, 0x61); + let original_proof = proof_for(&fixture, &original); + let commitment_substituted_wire = ticket_wire( + &fixture.ticket, + fixture.ticket.account_id(), + fixture.ticket.proposed_device_id(), + fixture.ticket.proposed_endpoint(), + Digest::new(HashAlgorithm::Blake3_256, [0x91; 32]), + ); + let commitment_substituted_ticket = + PairingTicket::from_canonical_bytes(&commitment_substituted_wire).unwrap(); + let commitment_substituted = + connect_ticket(&fixture, commitment_substituted_ticket, 0x31, 0x41, 0x61).unwrap(); + assert!(matches!( + commitment_substituted.verify_possession(original_proof), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn individual_agreement_and_pairing_ephemeral_mac_corruption_fail() { + let fixture = ticket_fixture(); + let connected = connect_fixture(&fixture, 0x61); + let mut agreement_corrupted = proof_for(&fixture, &connected); + agreement_corrupted.agreement_mac.0[0] ^= 1; + assert_eq!( + connected + .verify_possession(agreement_corrupted) + .unwrap_err(), + IdentityError::InvalidProof + ); + + let connected = connect_fixture(&fixture, 0x61); + let mut ephemeral_corrupted = proof_for(&fixture, &connected); + ephemeral_corrupted.pairing_ephemeral_mac.0[0] ^= 1; + assert_eq!( + connected + .verify_possession(ephemeral_corrupted) + .unwrap_err(), + IdentityError::InvalidProof + ); +} + +#[test] +fn transcript_rejects_all_cross_device_role_and_ephemeral_public_key_reuse() { + let fixture = ticket_fixture(); + let controller_keys = &fixture.controller_secrets; + let reused_proposed_endpoint = fixture + .ticket + .proposed_device() + .endpoint_key() + .as_signing_key(); + let controller = DeviceDescriptor::new( + reused_proposed_endpoint, + controller_keys.agreement.public_key().unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*controller_keys.endpoint.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + connect_with_controller_and_connection(&fixture, controller, 0x51), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let reused_proposed_application = fixture.ticket.proposed_device().application_signing_key(); + let controller = DeviceDescriptor::new( + reused_proposed_application, + controller_keys.agreement.public_key().unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*controller_keys.endpoint.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + connect_with_controller_and_connection(&fixture, controller, 0x51), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let pairing_ephemeral_reuses_agreement = ticket_fixture_with_rng(11); + assert!(matches!( + connect_with_controller_and_connection( + &pairing_ephemeral_reuses_agreement, + pairing_ephemeral_reuses_agreement + .controller_secrets + .descriptor(), + 0x51, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + assert!(matches!( + connect_with_controller_and_connection( + &fixture, + fixture.controller_secrets.descriptor(), + 11, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + assert!(matches!( + connect_with_controller_and_connection( + &fixture, + fixture.controller_secrets.descriptor(), + 0x5a, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn wrong_agreement_or_ticket_secret_cannot_create_a_valid_proof() { + let fixture = ticket_fixture(); + let connected = connect_fixture(&fixture, 0x61); + let transcript = connected.transcript(); + let application = fixture + .proposed_secrets + .application + .sign(&transcript.application_possession_signing_bytes().unwrap()); + let endpoint = fixture + .proposed_secrets + .endpoint + .sign(&transcript.endpoint_possession_signing_bytes().unwrap()); + let wrong_agreement = AgreementSecretKey::from_bytes([0x71; 32]); + assert!(matches!( + PairingPossessionProof::create( + transcript, + &fixture.ticket_secrets, + &wrong_agreement, + ProtocolSignature::ed25519(application.to_bytes()), + ProtocolSignature::ed25519(endpoint.to_bytes()), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let other = ticket_fixture_with_rng(0x6a); + assert!(matches!( + PairingPossessionProof::create( + transcript, + &other.ticket_secrets, + &fixture.proposed_secrets.agreement, + ProtocolSignature::ed25519(application.to_bytes()), + ProtocolSignature::ed25519(endpoint.to_bytes()), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn short_auth_mismatch_cancels_without_proposal() { + let fixture = ticket_fixture(); + let connected = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &connected); + let proven = connected.verify_possession(proof).unwrap(); + let short_auth = proven.short_auth_string(); + let wrong_short_auth = crate::ShortAuthString::new(*b"000000").unwrap(); + let controller = signed_confirmation( + &proven, + &fixture.controller_secrets.endpoint, + ConfirmationParticipant::Controller, + short_auth, + Timestamp::from_unix_millis(3_000), + ); + let proposed = signed_confirmation( + &proven, + &fixture.proposed_secrets.endpoint, + ConfirmationParticipant::ProposedDevice, + wrong_short_auth, + Timestamp::from_unix_millis(3_001), + ); + assert!(matches!( + proven.confirm(controller, proposed), + PairingConfirmationOutcome::Cancelled(_) + )); +} + +#[test] +fn one_proven_holder_cannot_synthesize_both_participant_confirmations() { + let fixture = ticket_fixture(); + let connected = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &connected); + let proven = connected.verify_possession(proof).unwrap(); + let short_auth = proven.short_auth_string(); + let controller = signed_confirmation( + &proven, + &fixture.controller_secrets.endpoint, + ConfirmationParticipant::Controller, + short_auth, + Timestamp::from_unix_millis(3_000), + ); + let forged_proposed = signed_confirmation( + &proven, + &fixture.controller_secrets.endpoint, + ConfirmationParticipant::ProposedDevice, + short_auth, + Timestamp::from_unix_millis(3_001), + ); + + assert!(matches!( + proven.confirm(controller, forged_proposed), + PairingConfirmationOutcome::Cancelled(_) + )); + + let connected = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &connected); + let proven = connected.verify_possession(proof).unwrap(); + let short_auth = proven.short_auth_string(); + let forged_controller = signed_confirmation( + &proven, + &fixture.proposed_secrets.endpoint, + ConfirmationParticipant::Controller, + short_auth, + Timestamp::from_unix_millis(3_000), + ); + let proposed = signed_confirmation( + &proven, + &fixture.proposed_secrets.endpoint, + ConfirmationParticipant::ProposedDevice, + short_auth, + Timestamp::from_unix_millis(3_001), + ); + assert!(matches!( + proven.confirm(forged_controller, proposed), + PairingConfirmationOutcome::Cancelled(_) + )); +} + +#[test] +fn confirmation_evidence_binds_participant_and_authenticated_session() { + let fixture = ticket_fixture(); + let connected = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &connected); + let proven = connected.verify_possession(proof).unwrap(); + let short_auth = proven.short_auth_string(); + let controller_bytes = proven + .confirmation_signing_bytes( + ConfirmationParticipant::Controller, + short_auth, + Timestamp::from_unix_millis(3_000), + ) + .unwrap(); + let proposed_bytes = proven + .confirmation_signing_bytes( + ConfirmationParticipant::ProposedDevice, + short_auth, + Timestamp::from_unix_millis(3_000), + ) + .unwrap(); + assert!(controller_bytes.starts_with(b"KRIKOS-ID/pairing-confirmation/v1\0")); + assert_ne!(controller_bytes, proposed_bytes); + + let other_session = connect_ticket(&fixture, fixture.ticket.clone(), 0x32, 0x41, 0x61).unwrap(); + let other_proof = proof_for(&fixture, &other_session); + let other_proven = other_session.verify_possession(other_proof).unwrap(); + let other_bytes = other_proven + .confirmation_signing_bytes( + ConfirmationParticipant::Controller, + other_proven.short_auth_string(), + Timestamp::from_unix_millis(3_000), + ) + .unwrap(); + assert_ne!(controller_bytes, other_bytes); +} + +#[test] +fn replay_store_reopen_and_atomic_failure_never_emit_a_proposal() { + let fixture = ticket_fixture(); + let confirmed = confirmed_fixture(&fixture); + let mut store = DurableNonceStore { + consumed: Vec::new(), + fail_next: true, + }; + assert!( + store.consumed.is_empty(), + "no pre-consume proposal side effect" + ); + assert!(matches!( + confirmed.consume(&mut store, Timestamp::from_unix_millis(4_000)), + Err(PairingConsumeError::Store("injected durable write failure")) + )); + assert!(store.consumed.is_empty()); + + let confirmed = confirmed_fixture(&fixture); + let _proposal = consumed_proposal( + confirmed + .consume(&mut store, Timestamp::from_unix_millis(4_000)) + .unwrap(), + ); + let reopened = DurableNonceStore { + consumed: store.consumed.clone(), + fail_next: false, + }; + let mut reopened = reopened; + let replay = confirmed_fixture(&fixture); + assert!(matches!( + replay.consume(&mut reopened, Timestamp::from_unix_millis(4_001)), + Err(PairingConsumeError::AlreadyConsumed) + )); +} + +#[test] +fn expiry_is_terminal_and_durably_tombstoned_at_consume() { + let fixture = ticket_fixture(); + let mut admission_store = DurableNonceStore::default(); + let admission = PairingCeremony::accept( + fixture.ticket.clone(), + &mut admission_store, + Timestamp::from_unix_millis(601_001), + ) + .unwrap(); + assert!(matches!(admission, PairingAdmission::Expired(_))); + assert_eq!(admission_store.consumed.len(), 1); + + let confirmed = confirmed_fixture(&fixture); + let mut store = DurableNonceStore::default(); + assert!(matches!( + confirmed + .consume(&mut store, Timestamp::from_unix_millis(601_001)) + .unwrap(), + PairingConsumeOutcome::Expired(_) + )); + assert_eq!(store.consumed.len(), 1); + let replay = confirmed_fixture(&fixture); + assert!(matches!( + replay.consume(&mut store, Timestamp::from_unix_millis(4_000)), + Err(PairingConsumeError::AlreadyConsumed) + )); +} + +#[test] +fn expired_admission_cannot_revive_after_clock_rollback() { + let fixture = ticket_fixture(); + let mut store = DurableNonceStore::default(); + let expired = PairingCeremony::accept( + fixture.ticket.clone(), + &mut store, + Timestamp::from_unix_millis(601_001), + ) + .unwrap(); + assert!(matches!(expired, PairingAdmission::Expired(_))); + + let mut reopened = store.clone(); + let rolled_back = PairingCeremony::accept( + fixture.ticket.clone(), + &mut reopened, + Timestamp::from_unix_millis(4_000), + ); + assert!(matches!( + rolled_back, + Err(PairingConsumeError::AlreadyConsumed) + )); +} + +#[test] +fn production_memory_nonce_store_keeps_expiry_tombstones_across_clock_rollback() { + let fixture = ticket_fixture(); + let mut store = MemoryPairingNonceStore::new(); + let expired = PairingCeremony::accept( + fixture.ticket.clone(), + &mut store, + Timestamp::from_unix_millis(601_001), + ) + .unwrap(); + assert!(matches!(expired, PairingAdmission::Expired(_))); + + assert!(matches!( + PairingCeremony::accept( + fixture.ticket, + &mut store, + Timestamp::from_unix_millis(4_000), + ), + Err(PairingConsumeError::AlreadyConsumed) + )); +} + +#[test] +fn pairing_future_skew_and_expiry_boundaries_are_exact() { + let proposed = DeviceSecrets::new(10); + let request = PairingTicketRequest::new( + account_id(1), + proposed.descriptor(), + Vec::new(), + Timestamp::from_unix_millis(121_000), + Timestamp::from_unix_millis(122_000), + Extensions::default(), + ) + .unwrap(); + let (ticket, _) = PairingTicket::issue_with_rng(request, &mut RepeatingRng(0x5a)).unwrap(); + assert!(matches!( + accept_ticket(ticket.clone(), Timestamp::from_unix_millis(1_000)).unwrap(), + PairingAdmission::Issued(_) + )); + assert!(matches!( + accept_ticket(ticket.clone(), Timestamp::from_unix_millis(999)), + Err(PairingConsumeError::Protocol( + IdentityError::InvalidRelationship { .. } + )) + )); + assert!(matches!( + accept_ticket(ticket.clone(), Timestamp::from_unix_millis(122_000)).unwrap(), + PairingAdmission::Issued(_) + )); + assert!(matches!( + accept_ticket(ticket, Timestamp::from_unix_millis(122_001)).unwrap(), + PairingAdmission::Expired(_) + )); +} + +#[test] +fn duplicate_reorder_and_restart_model_emits_at_most_one_proposal() { + let fixture = ticket_fixture(); + for actions in [[0_u8, 1, 0, 2], [1_u8, 0, 2, 0], [2_u8, 0, 1, 0]] { + let mut store = DurableNonceStore::default(); + let mut proposals = 0_u8; + for action in actions { + match action { + 0 => { + let confirmed = confirmed_fixture(&fixture); + match confirmed.consume(&mut store, Timestamp::from_unix_millis(4_000)) { + Ok(PairingConsumeOutcome::Consumed(_)) => proposals += 1, + Err(PairingConsumeError::AlreadyConsumed) => {} + unexpected => panic!("unexpected model outcome: {unexpected:?}"), + } + } + 1 => { + store = DurableNonceStore { + consumed: store.consumed.clone(), + fail_next: false, + }; + } + 2 => { + let connected = connect_fixture(&fixture, 0x61); + let other_session = + connect_ticket(&fixture, fixture.ticket.clone(), 0x32, 0x41, 0x61).unwrap(); + assert!( + other_session + .verify_possession(proof_for(&fixture, &connected)) + .is_err() + ); + } + _ => unreachable!(), + } + } + assert_eq!(proposals, 1); + } +} + +fn assert_canonical_corpus_seed(input: &[u8], expected_selector: usize) { + const DECODER_COUNT: usize = 6; + let Some((&selector, encoded_payload)) = input.split_first() else { + panic!("fuzz corpus seed must include a selector"); + }; + assert_eq!(usize::from(selector) % DECODER_COUNT, expected_selector); + let hexadecimal = encoded_payload + .strip_prefix(b"hex:") + .expect("checked-in text corpus seed must use the hex wrapper"); + let hexadecimal: String = std::str::from_utf8(hexadecimal) + .expect("checked-in corpus hex must be UTF-8") + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect(); + let payload = hex::decode(hexadecimal).expect("checked-in corpus hex must decode"); + let decoded = T::from_canonical_bytes(&payload).unwrap_or_else(|error| { + panic!("selector {expected_selector} corpus payload must be canonical: {error:?}") + }); + assert_eq!(decoded.to_canonical_bytes().unwrap(), payload); +} + +#[test] +fn every_pairing_fuzz_selector_has_a_valid_canonical_seed() { + assert_canonical_corpus_seed::( + include_bytes!("../../../../fuzz/corpus/identity_pairing/seed.txt"), + 0, + ); + assert_canonical_corpus_seed::( + include_bytes!("../../../../fuzz/corpus/identity_pairing/selector-1-pairing-transcript"), + 1, + ); + assert_canonical_corpus_seed::( + include_bytes!("../../../../fuzz/corpus/identity_pairing/selector-2-pairing-proof"), + 2, + ); + assert_canonical_corpus_seed::( + include_bytes!( + "../../../../fuzz/corpus/identity_pairing/selector-3-device-authorization-proposal" + ), + 3, + ); + assert_canonical_corpus_seed::( + include_bytes!("../../../../fuzz/corpus/identity_pairing/selector-4-presence-challenge"), + 4, + ); + assert_canonical_corpus_seed::( + include_bytes!("../../../../fuzz/corpus/identity_pairing/selector-5-presence-proof"), + 5, + ); +} + +#[test] +fn secrets_are_redacted_and_short_auth_is_deterministic() { + let fixture = ticket_fixture(); + assert_eq!( + format!("{:?}", fixture.ticket_secrets), + "PairingTicketSecrets()" + ); + let first = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &first); + let first = first.verify_possession(proof).unwrap().short_auth_string(); + let second = connect_fixture(&fixture, 0x61); + let proof = proof_for(&fixture, &second); + let second = second.verify_possession(proof).unwrap().short_auth_string(); + let mut expected_ticket = hex::decode(concat!( + "010101010101010101010101010101010101010101010101010101010101010101010101", + "43a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c0173b2", + "d8b76aa9b53660032bc8f5d8bee3a3ae4e3b3a7fd49ade81f7347a34aa68010b513a", + "d9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d0001343b62", + "f7a40db173198b2d5d3ff1df419169d8e27f50f0f7b8845d993abdff0a01b0d08f3", + "5b4683381489afb32825e59152d47d19bc9e050d6d5a954984c9d1e2c010b513ad9", + "b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d1572656c6179", + "2e6578616d706c652e696e76616c6964014b2319918aa3b10e598e85505e5062aa4c", + "65babbf3d4c3eaf544c3ddb1ef090ae807a8d724" + )) + .unwrap(); + expected_ticket.extend_from_slice(&[0x5a; 32]); + expected_ticket.push(0); + assert_eq!( + fixture.ticket.to_canonical_bytes().unwrap(), + expected_ticket + ); + assert_eq!( + fixture.ticket.ticket_id().unwrap().as_digest().to_string(), + "b3:835f0016f42c6ba397fe992b3df1ef11c8567903554ce56a249dfc9869b8d4fc" + ); + assert_eq!(first.to_string(), "580158"); + assert_eq!(first, second); +} diff --git a/protocols/krikos-identity/src/policy.rs b/protocols/krikos-identity/src/policy.rs new file mode 100644 index 00000000000..f9401e91160 --- /dev/null +++ b/protocols/krikos-identity/src/policy.rs @@ -0,0 +1,1524 @@ +//! Weighted control, transparency-provider, and private recovery policies. + +use std::{cmp::Ordering, fmt}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + ControlPolicyId, ControllerId, Digest, DurationMillis, Extensions, IdentityError, + OperationKind, ProtocolVersion, ProviderPolicyId, ProviderPolicyVersion, ProviderQuorum, + RecoveryPolicyId, RequiredWeight, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + keys::{ControllerClass, ControllerDescriptor, ProviderDescriptor}, + limits::{ + MAX_CONTROLLERS, MAX_POLICY_RULES, MAX_RECOVERY_GUARDIANS, MAX_TRANSPARENCY_PROVIDERS, + }, + schema::BoundedVec, +}; + +/// Sorted, duplicate-free explicit controller identifiers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControllerIdSet(Vec); + +impl ControllerIdSet { + /// Validate, sort, and construct an explicit controller-ID set. + pub fn new(mut identifiers: Vec) -> Result { + identifiers.sort_unstable_by(compare_controller_ids); + Self::from_sorted(identifiers) + } + + /// Borrow the canonical sorted identifiers. + pub fn as_slice(&self) -> &[ControllerId] { + &self.0 + } + + fn from_sorted(identifiers: Vec) -> Result { + validate_nonempty_bounded_set( + "controller selector identifiers", + identifiers.len(), + MAX_CONTROLLERS, + )?; + for pair in identifiers.windows(2) { + match compare_controller_ids(&pair[0], &pair[1]) { + Ordering::Equal => { + return Err(IdentityError::DuplicateElement { + resource: "controller selector identifiers", + }); + } + Ordering::Greater => return Err(IdentityError::NonCanonical), + Ordering::Less => {} + } + } + Ok(Self(identifiers)) + } + + fn contains(&self, identifier: &ControllerId) -> bool { + self.0 + .binary_search_by(|candidate| compare_controller_ids(candidate, identifier)) + .is_ok() + } +} + +impl Serialize for ControllerIdSet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ControllerIdSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let identifiers = BoundedVec::::deserialize(deserializer)?; + Self::from_sorted(identifiers.into_vec()).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ControllerIdSet { + const RESOURCE: &'static str = "controller identifier set bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Sorted, duplicate-free explicit controller classes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControllerClassSet(Vec); + +impl ControllerClassSet { + /// Validate, sort, and construct an explicit controller-class set. + pub fn new(mut classes: Vec) -> Result { + classes.sort_unstable_by_key(|class| class.code()); + Self::from_sorted(classes) + } + + /// Borrow the canonical sorted classes. + pub fn as_slice(&self) -> &[ControllerClass] { + &self.0 + } + + fn from_sorted(classes: Vec) -> Result { + validate_nonempty_bounded_set( + "controller selector classes", + classes.len(), + MAX_CONTROLLERS, + )?; + for pair in classes.windows(2) { + if pair[0].code() == pair[1].code() { + return Err(IdentityError::DuplicateElement { + resource: "controller selector classes", + }); + } + if pair[0].code() > pair[1].code() { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self(classes)) + } + + fn contains(&self, class: ControllerClass) -> bool { + self.0 + .binary_search_by_key(&class.code(), |candidate| candidate.code()) + .is_ok() + } +} + +impl Serialize for ControllerClassSet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ControllerClassSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let classes = BoundedVec::::deserialize(deserializer)?; + Self::from_sorted(classes.into_vec()).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ControllerClassSet { + const RESOURCE: &'static str = "controller class set bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Closed v1 selector for controllers eligible to satisfy one rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ControllerSelector { + /// Every active controller whose immutable scope permits the operation. + AnyActive, + /// A canonical explicit controller-ID set. + ControllerIds(ControllerIdSet), + /// A canonical explicit controller-class set. + ControllerClasses(ControllerClassSet), +} + +impl ControllerSelector { + /// Construct the any-active-controller selector. + pub const fn any_active() -> Self { + Self::AnyActive + } + + /// Validate and construct an explicit controller-ID selector. + pub fn controller_ids(identifiers: Vec) -> Result { + Ok(Self::ControllerIds(ControllerIdSet::new(identifiers)?)) + } + + /// Validate and construct an explicit controller-class selector. + pub fn controller_classes(classes: Vec) -> Result { + Ok(Self::ControllerClasses(ControllerClassSet::new(classes)?)) + } + + /// Test whether a controller descriptor belongs to this selector. + pub fn matches_controller( + &self, + descriptor: &ControllerDescriptor, + ) -> Result { + match self { + Self::AnyActive => Ok(true), + Self::ControllerIds(identifiers) => Ok(identifiers.contains(&descriptor.id()?)), + Self::ControllerClasses(classes) => Ok(classes.contains(descriptor.class())), + } + } + + fn explicit_ids(&self) -> Option<&[ControllerId]> { + match self { + Self::ControllerIds(identifiers) => Some(identifiers.as_slice()), + Self::AnyActive | Self::ControllerClasses(_) => None, + } + } +} + +impl Serialize for ControllerSelector { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::AnyActive => ( + 1_u16, + Option::<&ControllerIdSet>::None, + Option::<&ControllerClassSet>::None, + ) + .serialize(serializer), + Self::ControllerIds(identifiers) => ( + 2_u16, + Some(identifiers), + Option::<&ControllerClassSet>::None, + ) + .serialize(serializer), + Self::ControllerClasses(classes) => { + (3_u16, Option::<&ControllerIdSet>::None, Some(classes)).serialize(serializer) + } + } + } +} + +impl<'de> Deserialize<'de> for ControllerSelector { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, identifiers, classes) = + <(u16, Option, Option)>::deserialize( + deserializer, + )?; + match (code, identifiers, classes) { + (1, None, None) => Ok(Self::AnyActive), + (2, Some(identifiers), None) => Ok(Self::ControllerIds(identifiers)), + (3, None, Some(classes)) => Ok(Self::ControllerClasses(classes)), + (1..=3, _, _) => Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "controller selector payload", + })), + (unsupported, _, _) => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "controller selector", + code: unsupported, + })), + } + } +} + +impl CanonicalCodec for ControllerSelector { + const RESOURCE: &'static str = "controller selector bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Minimum signed-provider freshness evidence required by one policy rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderFreshness { + required: ProviderQuorum, + maximum_age: DurationMillis, +} + +impl ProviderFreshness { + /// Construct a nonzero bounded freshness requirement. + pub fn new( + required: ProviderQuorum, + maximum_age: DurationMillis, + ) -> Result { + if usize::from(required.get()) > MAX_TRANSPARENCY_PROVIDERS { + return Err(IdentityError::limit( + "provider freshness quorum", + usize::from(required.get()), + MAX_TRANSPARENCY_PROVIDERS, + )); + } + if maximum_age.get() == 0 { + return Err(IdentityError::ZeroValue { + resource: "provider freshness maximum age", + }); + } + Ok(Self { + required, + maximum_age, + }) + } + + /// Required distinct configured-provider observations. + pub const fn required(self) -> ProviderQuorum { + self.required + } + + /// Maximum signed-provider evidence age. + pub const fn maximum_age(self) -> DurationMillis { + self.maximum_age + } +} + +impl Serialize for ProviderFreshness { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.required, self.maximum_age).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProviderFreshness { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (required, maximum_age) = + <(ProviderQuorum, DurationMillis)>::deserialize(deserializer)?; + Self::new(required, maximum_age).map_err(de::Error::custom) + } +} + +/// Closed v1 freshness requirement attached to a policy rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FreshnessRequirement { + /// Validate relative to the latest locally known valid state. + LatestKnown, + /// Require signed observations from a bounded provider quorum. + ProviderQuorum(ProviderFreshness), +} + +impl FreshnessRequirement { + /// Construct local latest-known-state freshness. + pub const fn latest_known() -> Self { + Self::LatestKnown + } + + /// Construct signed-provider freshness. + pub const fn provider_quorum(requirement: ProviderFreshness) -> Self { + Self::ProviderQuorum(requirement) + } +} + +impl Serialize for FreshnessRequirement { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::LatestKnown => (1_u16, Option::::None).serialize(serializer), + Self::ProviderQuorum(requirement) => (2_u16, Some(*requirement)).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for FreshnessRequirement { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, requirement) = <(u16, Option)>::deserialize(deserializer)?; + match (code, requirement) { + (1, None) => Ok(Self::LatestKnown), + (2, Some(requirement)) => Ok(Self::ProviderQuorum(requirement)), + (1 | 2, _) => Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "freshness requirement payload", + })), + (unsupported, _) => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "freshness requirement", + code: unsupported, + })), + } + } +} + +/// One weighted, default-deny account-control authorization rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyRule { + operation: OperationKind, + required_weight: RequiredWeight, + eligible_controllers: ControllerSelector, + freshness: FreshnessRequirement, + delay: Option, + extensions: Extensions, +} + +impl PolicyRule { + /// Construct one canonical policy rule. + pub fn new( + operation: OperationKind, + required_weight: RequiredWeight, + eligible_controllers: ControllerSelector, + freshness: FreshnessRequirement, + delay: Option, + extensions: Extensions, + ) -> Result { + if delay.is_some_and(|value| value.get() == 0) { + return Err(IdentityError::ZeroValue { + resource: "policy delay", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + operation, + required_weight, + eligible_controllers, + freshness, + delay, + extensions, + }) + } + + /// Account operation governed by this rule. + pub const fn operation(&self) -> OperationKind { + self.operation + } + + /// Nonzero required controller weight. + pub const fn required_weight(&self) -> RequiredWeight { + self.required_weight + } + + /// Eligible controller selector. + pub const fn eligible_controllers(&self) -> &ControllerSelector { + &self.eligible_controllers + } + + /// Freshness requirement signed into admission evidence. + pub const fn freshness(&self) -> FreshnessRequirement { + self.freshness + } + + /// Optional nonzero operation delay. + pub const fn delay(&self) -> Option { + self.delay + } + + /// Signed forward-compatible extensions. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl Serialize for PolicyRule { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ( + self.operation, + self.required_weight, + &self.eligible_controllers, + self.freshness, + self.delay, + &self.extensions, + ) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PolicyRule { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (operation, required_weight, selector, freshness, delay, extensions) = + <( + OperationKind, + RequiredWeight, + ControllerSelector, + FreshnessRequirement, + Option, + Extensions, + )>::deserialize(deserializer)?; + Self::new( + operation, + required_weight, + selector, + freshness, + delay, + extensions, + ) + .map_err(de::Error::custom) + } +} + +/// Versioned sorted weighted account-control policy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControlPolicy { + protocol_version: ProtocolVersion, + rules: Vec, + default_deny: bool, + extensions: Extensions, +} + +impl ControlPolicy { + /// Validate, sort, and construct a default-deny v1 policy. + pub fn new(mut rules: Vec, extensions: Extensions) -> Result { + rules.sort_unstable_by_key(|rule| rule.operation().code()); + Self::from_sorted(rules, true, extensions) + } + + fn from_sorted( + rules: Vec, + default_deny: bool, + extensions: Extensions, + ) -> Result { + validate_nonempty_bounded_set("control policy rules", rules.len(), MAX_POLICY_RULES)?; + if !default_deny { + return Err(IdentityError::InvalidPolicy { + resource: "non-default-deny control", + }); + } + for pair in rules.windows(2) { + if pair[0].operation().code() == pair[1].operation().code() { + return Err(IdentityError::DuplicateElement { + resource: "control policy rules", + }); + } + if pair[0].operation().code() > pair[1].operation().code() { + return Err(IdentityError::NonCanonical); + } + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + rules, + default_deny, + extensions, + }) + } + + /// Derive the canonical control-policy identifier. + pub fn id(&self) -> Result { + ControlPolicyId::derive(self) + } + + /// Canonical rules sorted by operation code. + pub fn rules(&self) -> &[PolicyRule] { + &self.rules + } + + /// Find the sole canonical rule for an operation, or deny when absent. + pub fn rule_for(&self, operation: OperationKind) -> Option<&PolicyRule> { + self.rules + .binary_search_by_key(&operation, PolicyRule::operation) + .ok() + .map(|index| &self.rules[index]) + } + + /// V1 always denies operations without a rule. + pub const fn default_deny(&self) -> bool { + self.default_deny + } + + /// Signed forward-compatible extensions. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Validate every rule against one bounded active-controller set. + pub fn validate_satisfiable( + &self, + controllers: &[ControllerDescriptor], + ) -> Result<(), IdentityError> { + validate_active_controllers(controllers)?; + for rule in &self.rules { + if matches!( + rule.operation(), + OperationKind::BeginRecovery + | OperationKind::CancelRecovery + | OperationKind::FinalizeRecovery + ) { + // Recovery authorization is owned by RecoveryPolicy. These control-policy + // entries are only the default-deny gate plus freshness/delay configuration. + continue; + } + validate_explicit_controller_references(rule.eligible_controllers(), controllers)?; + let total = + eligible_weight(rule.eligible_controllers(), rule.operation(), controllers)?; + if u64::from(rule.required_weight().get()) > total { + return Err(IdentityError::UnsatisfiableThreshold); + } + } + Ok(()) + } +} + +impl Serialize for ControlPolicy { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ( + self.protocol_version, + self.rules.as_slice(), + self.default_deny, + &self.extensions, + ) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ControlPolicy { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (_version, rules, default_deny, extensions) = <( + ProtocolVersion, + BoundedVec, + bool, + Extensions, + )>::deserialize(deserializer)?; + Self::from_sorted(rules.into_vec(), default_deny, extensions).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ControlPolicy { + const RESOURCE: &'static str = "control policy bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Frozen v1 transparency-provider key-rotation rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderRotationRule { + /// Provider replacement requires an account-authorized policy event. + AccountEventOnly, +} + +impl Serialize for ProviderRotationRule { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + 1_u16.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProviderRotationRule { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match u16::deserialize(deserializer)? { + 1 => Ok(Self::AccountEventOnly), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "provider rotation rule", + code: unsupported, + })), + } + } +} + +/// Validated replicated transparency-provider mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReplicatedProviderPolicy { + providers: Vec, + sufficient_threshold: ProviderQuorum, + preferred_replication: ProviderQuorum, + maximum_evidence_age: DurationMillis, + rotation_rule: ProviderRotationRule, +} + +impl ReplicatedProviderPolicy { + fn new( + providers: Vec, + sufficient_threshold: ProviderQuorum, + preferred_replication: ProviderQuorum, + maximum_evidence_age: DurationMillis, + ) -> Result { + if providers.len() > MAX_TRANSPARENCY_PROVIDERS { + return Err(IdentityError::limit( + "provider policy providers", + providers.len(), + MAX_TRANSPARENCY_PROVIDERS, + )); + } + let providers = sort_providers(providers)?; + Self::from_sorted( + providers, + sufficient_threshold, + preferred_replication, + maximum_evidence_age, + ProviderRotationRule::AccountEventOnly, + ) + } + + fn from_sorted( + providers: Vec, + sufficient_threshold: ProviderQuorum, + preferred_replication: ProviderQuorum, + maximum_evidence_age: DurationMillis, + rotation_rule: ProviderRotationRule, + ) -> Result { + validate_nonempty_bounded_set( + "provider policy providers", + providers.len(), + MAX_TRANSPARENCY_PROVIDERS, + )?; + validate_sorted_providers(&providers)?; + let sufficient = usize::from(sufficient_threshold.get()); + let preferred = usize::from(preferred_replication.get()); + if sufficient > preferred || preferred > providers.len() { + return Err(IdentityError::InvalidPolicy { + resource: "provider threshold", + }); + } + if maximum_evidence_age.get() == 0 { + return Err(IdentityError::ZeroValue { + resource: "provider maximum evidence age", + }); + } + Ok(Self { + providers, + sufficient_threshold, + preferred_replication, + maximum_evidence_age, + rotation_rule, + }) + } + + /// Canonical provider descriptors sorted by provider ID. + pub fn providers(&self) -> &[ProviderDescriptor] { + &self.providers + } + + /// Minimum provider observations sufficient for account policy. + pub const fn sufficient_threshold(&self) -> ProviderQuorum { + self.sufficient_threshold + } + + /// Preferred replication count. + pub const fn preferred_replication(&self) -> ProviderQuorum { + self.preferred_replication + } + + /// Maximum accepted age of signed provider evidence. + pub const fn maximum_evidence_age(&self) -> DurationMillis { + self.maximum_evidence_age + } +} + +impl Serialize for ReplicatedProviderPolicy { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ( + self.providers.as_slice(), + self.sufficient_threshold, + self.preferred_replication, + self.maximum_evidence_age, + self.rotation_rule, + ) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ReplicatedProviderPolicy { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (providers, sufficient, preferred, maximum_age, rotation_rule) = + <( + BoundedVec, + ProviderQuorum, + ProviderQuorum, + DurationMillis, + ProviderRotationRule, + )>::deserialize(deserializer)?; + Self::from_sorted( + providers.into_vec(), + sufficient, + preferred, + maximum_age, + rotation_rule, + ) + .map_err(de::Error::custom) + } +} + +/// Mutually exclusive local-only or replicated provider policy mode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderMode { + /// No account-level provider requirement. + LocalOnly, + /// A bounded configured provider set and thresholds. + Replicated(ReplicatedProviderPolicy), +} + +impl Serialize for ProviderMode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::LocalOnly => { + (1_u16, Option::<&ReplicatedProviderPolicy>::None).serialize(serializer) + } + Self::Replicated(policy) => (2_u16, Some(policy)).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for ProviderMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, policy) = <(u16, Option)>::deserialize(deserializer)?; + match (code, policy) { + (1, None) => Ok(Self::LocalOnly), + (2, Some(policy)) => Ok(Self::Replicated(policy)), + (1 | 2, _) => Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "provider mode payload", + })), + (unsupported, _) => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "provider mode", + code: unsupported, + })), + } + } +} + +/// Versioned account minimum for transparency-provider evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderPolicy { + protocol_version: ProtocolVersion, + policy_version: ProviderPolicyVersion, + mode: ProviderMode, + extensions: Extensions, +} + +impl ProviderPolicy { + /// Construct an explicit local-only provider policy. + pub fn local_only( + policy_version: ProviderPolicyVersion, + extensions: Extensions, + ) -> Result { + Self::from_mode(policy_version, ProviderMode::LocalOnly, extensions) + } + + /// Validate, sort, and construct a replicated provider policy. + pub fn replicated( + policy_version: ProviderPolicyVersion, + providers: Vec, + sufficient_threshold: ProviderQuorum, + preferred_replication: ProviderQuorum, + maximum_evidence_age: DurationMillis, + extensions: Extensions, + ) -> Result { + let replicated = ReplicatedProviderPolicy::new( + providers, + sufficient_threshold, + preferred_replication, + maximum_evidence_age, + )?; + Self::from_mode( + policy_version, + ProviderMode::Replicated(replicated), + extensions, + ) + } + + fn from_mode( + policy_version: ProviderPolicyVersion, + mode: ProviderMode, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + policy_version, + mode, + extensions, + }) + } + + /// Derive the canonical provider-policy identifier. + pub fn id(&self) -> Result { + ProviderPolicyId::derive(self) + } + + /// Monotonic provider-policy version. + pub const fn policy_version(&self) -> ProviderPolicyVersion { + self.policy_version + } + + /// Mutually exclusive provider mode. + pub const fn mode(&self) -> &ProviderMode { + &self.mode + } + + /// Configured provider descriptors, absent for local-only mode. + pub fn providers(&self) -> Option<&[ProviderDescriptor]> { + match &self.mode { + ProviderMode::LocalOnly => None, + ProviderMode::Replicated(policy) => Some(policy.providers()), + } + } + + /// Signed forward-compatible extensions. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } +} + +impl Serialize for ProviderPolicy { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ( + self.protocol_version, + self.policy_version, + &self.mode, + &self.extensions, + ) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProviderPolicy { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (_version, policy_version, mode, extensions) = <( + ProtocolVersion, + ProviderPolicyVersion, + ProviderMode, + Extensions, + )>::deserialize(deserializer)?; + Self::from_mode(policy_version, mode, extensions).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for ProviderPolicy { + const RESOURCE: &'static str = "provider policy bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Monotonic recovery-policy revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct RecoveryPolicyVersion(u64); + +impl RecoveryPolicyVersion { + /// Initial recovery-policy revision. + pub const GENESIS: Self = Self(0); + + /// Construct from an exact wire value. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Return the exact policy revision. + pub const fn get(self) -> u64 { + self.0 + } + + /// Advance exactly once, rejecting exhaustion. + pub fn checked_next(self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "recovery policy version", + }) + } +} + +impl CanonicalCodec for RecoveryPolicyVersion { + const RESOURCE: &'static str = "recovery policy version bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Blinded commitment to the private recovery guardian set. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct GuardianSetRoot(Digest); + +impl GuardianSetRoot { + /// Construct a nonzero domain-separated guardian-set commitment. + pub fn new(digest: Digest) -> Result { + if digest.as_bytes() == &[0; 32] { + return Err(IdentityError::InvalidIdentifier { + resource: "guardian set root", + }); + } + Ok(Self(digest)) + } + + /// Borrow the commitment digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } +} + +impl fmt::Debug for GuardianSetRoot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("GuardianSetRoot") + .field(&self.0) + .finish() + } +} + +impl<'de> Deserialize<'de> for GuardianSetRoot { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(Digest::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for GuardianSetRoot { + const RESOURCE: &'static str = "guardian set root bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Controller authority required to start or cancel recovery. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ControllerThreshold { + selector: ControllerSelector, + required_weight: RequiredWeight, +} + +impl ControllerThreshold { + /// Construct an explicit controller threshold. + pub const fn new(selector: ControllerSelector, required_weight: RequiredWeight) -> Self { + Self { + selector, + required_weight, + } + } + + /// Eligible recovery controllers. + pub const fn selector(&self) -> &ControllerSelector { + &self.selector + } + + /// Nonzero required controller weight. + pub const fn required_weight(&self) -> RequiredWeight { + self.required_weight + } + + /// Validate this threshold against active controllers allowed to begin and cancel recovery. + pub fn validate_satisfiable( + &self, + controllers: &[ControllerDescriptor], + ) -> Result<(), IdentityError> { + validate_active_controllers(controllers)?; + validate_explicit_controller_references(&self.selector, controllers)?; + for operation in [OperationKind::BeginRecovery, OperationKind::CancelRecovery] { + let total = eligible_weight(&self.selector, operation, controllers)?; + if u64::from(self.required_weight.get()) > total { + return Err(IdentityError::UnsatisfiableThreshold); + } + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for ControllerThreshold { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (selector, required_weight) = + <(ControllerSelector, RequiredWeight)>::deserialize(deserializer)?; + Ok(Self::new(selector, required_weight)) + } +} + +/// Public aggregate parameters for a private guardian authority set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GuardianThreshold { + guardian_set_root: GuardianSetRoot, + guardian_count: u16, + total_weight: u64, + required_weight: RequiredWeight, +} + +impl GuardianThreshold { + /// Construct bounded guardian-set aggregate parameters. + pub fn new( + guardian_set_root: GuardianSetRoot, + guardian_count: u16, + total_weight: u64, + required_weight: RequiredWeight, + ) -> Result { + if guardian_count == 0 { + return Err(IdentityError::ZeroValue { + resource: "recovery guardian count", + }); + } + if usize::from(guardian_count) > MAX_RECOVERY_GUARDIANS { + return Err(IdentityError::limit( + "recovery guardian count", + usize::from(guardian_count), + MAX_RECOVERY_GUARDIANS, + )); + } + if total_weight == 0 { + return Err(IdentityError::ZeroValue { + resource: "recovery guardian total weight", + }); + } + let maximum_total = u64::from(guardian_count) + .checked_mul(u64::from(u32::MAX)) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "recovery guardian maximum total weight", + })?; + if total_weight < u64::from(guardian_count) + || total_weight > maximum_total + || u64::from(required_weight.get()) > total_weight + { + return Err(IdentityError::UnsatisfiableThreshold); + } + Ok(Self { + guardian_set_root, + guardian_count, + total_weight, + required_weight, + }) + } + + /// Blinded guardian-set commitment. + pub const fn guardian_set_root(&self) -> GuardianSetRoot { + self.guardian_set_root + } + + /// Number of committed nonzero-weight guardians. + pub const fn guardian_count(&self) -> u16 { + self.guardian_count + } + + /// Checked aggregate guardian weight. + pub const fn total_weight(&self) -> u64 { + self.total_weight + } + + /// Nonzero required guardian weight. + pub const fn required_weight(&self) -> RequiredWeight { + self.required_weight + } +} + +impl<'de> Deserialize<'de> for GuardianThreshold { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (root, count, total, required) = + <(GuardianSetRoot, u16, u64, RequiredWeight)>::deserialize(deserializer)?; + Self::new(root, count, total, required).map_err(de::Error::custom) + } +} + +/// Mutually exclusive public recovery-authority modes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecoveryAuthority { + /// A weighted controller selector. + ControllerThreshold(ControllerThreshold), + /// Aggregate parameters committing to private guardian identities. + GuardianThreshold(GuardianThreshold), +} + +impl RecoveryAuthority { + /// Construct controller-threshold recovery authority. + pub const fn controller_threshold(threshold: ControllerThreshold) -> Self { + Self::ControllerThreshold(threshold) + } + + /// Construct private guardian-threshold recovery authority. + pub const fn guardian_threshold(threshold: GuardianThreshold) -> Self { + Self::GuardianThreshold(threshold) + } +} + +impl Serialize for RecoveryAuthority { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::ControllerThreshold(threshold) => { + (1_u16, Some(threshold), Option::<&GuardianThreshold>::None).serialize(serializer) + } + Self::GuardianThreshold(threshold) => { + (2_u16, Option::<&ControllerThreshold>::None, Some(threshold)).serialize(serializer) + } + } + } +} + +impl<'de> Deserialize<'de> for RecoveryAuthority { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (code, controller, guardian) = + <(u16, Option, Option)>::deserialize( + deserializer, + )?; + match (code, controller, guardian) { + (1, Some(threshold), None) => Ok(Self::ControllerThreshold(threshold)), + (2, None, Some(threshold)) => Ok(Self::GuardianThreshold(threshold)), + (1 | 2, _, _) => Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "recovery authority payload", + })), + (unsupported, _, _) => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "recovery authority", + code: unsupported, + })), + } + } +} + +/// Versioned recovery authority, mandatory delay, and attempt lifetime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryPolicy { + protocol_version: ProtocolVersion, + policy_version: RecoveryPolicyVersion, + authority: RecoveryAuthority, + delay: DurationMillis, + lifetime: DurationMillis, + extensions: Extensions, +} + +impl RecoveryPolicy { + /// Construct a recovery policy with a nonempty finalization window. + pub fn new( + policy_version: RecoveryPolicyVersion, + authority: RecoveryAuthority, + delay: DurationMillis, + lifetime: DurationMillis, + extensions: Extensions, + ) -> Result { + if delay.get() == 0 { + return Err(IdentityError::ZeroValue { + resource: "recovery delay", + }); + } + if lifetime.get() == 0 { + return Err(IdentityError::ZeroValue { + resource: "recovery lifetime", + }); + } + if lifetime.get() <= delay.get() { + return Err(IdentityError::InvalidPolicy { + resource: "recovery finalization window", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + policy_version, + authority, + delay, + lifetime, + extensions, + }) + } + + /// Derive the canonical recovery-policy identifier. + pub fn id(&self) -> Result { + RecoveryPolicyId::derive(self) + } + + /// Monotonic recovery-policy revision. + pub const fn policy_version(&self) -> RecoveryPolicyVersion { + self.policy_version + } + + /// Explicit controller or private guardian authority. + pub const fn authority(&self) -> &RecoveryAuthority { + &self.authority + } + + /// Mandatory provider-observed security delay. + pub const fn delay(&self) -> DurationMillis { + self.delay + } + + /// Maximum lifetime of one authoritative recovery attempt. + pub const fn lifetime(&self) -> DurationMillis { + self.lifetime + } + + /// Signed forward-compatible extensions. + pub const fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Validate controller-threshold authority against active controllers. + pub fn validate_controller_authority( + &self, + controllers: &[ControllerDescriptor], + ) -> Result<(), IdentityError> { + match &self.authority { + RecoveryAuthority::ControllerThreshold(threshold) => { + threshold.validate_satisfiable(controllers) + } + RecoveryAuthority::GuardianThreshold(_) => Ok(()), + } + } +} + +impl Serialize for RecoveryPolicy { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + ( + self.protocol_version, + self.policy_version, + &self.authority, + self.delay, + self.lifetime, + &self.extensions, + ) + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RecoveryPolicy { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (_version, policy_version, authority, delay, lifetime, extensions) = + <( + ProtocolVersion, + RecoveryPolicyVersion, + RecoveryAuthority, + DurationMillis, + DurationMillis, + Extensions, + )>::deserialize(deserializer)?; + Self::new(policy_version, authority, delay, lifetime, extensions).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for RecoveryPolicy { + const RESOURCE: &'static str = "recovery policy bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +fn compare_controller_ids(left: &ControllerId, right: &ControllerId) -> Ordering { + compare_digests(left.as_digest(), right.as_digest()) +} + +fn compare_digests(left: &Digest, right: &Digest) -> Ordering { + left.algorithm() + .code() + .cmp(&right.algorithm().code()) + .then_with(|| left.as_bytes().cmp(right.as_bytes())) +} + +fn validate_nonempty_bounded_set( + resource: &'static str, + length: usize, + maximum: usize, +) -> Result<(), IdentityError> { + if length == 0 { + return Err(IdentityError::EmptyCollection { resource }); + } + if length > maximum { + return Err(IdentityError::limit(resource, length, maximum)); + } + Ok(()) +} + +fn validate_active_controllers(controllers: &[ControllerDescriptor]) -> Result<(), IdentityError> { + validate_nonempty_bounded_set("active controllers", controllers.len(), MAX_CONTROLLERS)?; + for (index, controller) in controllers.iter().enumerate() { + let identifier = controller.id()?; + for other in controllers.iter().skip(index + 1) { + if controller.signing_key() == other.signing_key() { + return Err(IdentityError::DuplicateSigningKey); + } + if identifier == other.id()? { + return Err(IdentityError::DuplicateElement { + resource: "active controller identifiers", + }); + } + } + } + Ok(()) +} + +fn validate_explicit_controller_references( + selector: &ControllerSelector, + controllers: &[ControllerDescriptor], +) -> Result<(), IdentityError> { + let Some(explicit_identifiers) = selector.explicit_ids() else { + return Ok(()); + }; + for explicit_identifier in explicit_identifiers { + let mut found = false; + for controller in controllers { + if controller.id()? == *explicit_identifier { + found = true; + break; + } + } + if !found { + return Err(IdentityError::InvalidRelationship { + resource: "controller selector active membership", + }); + } + } + Ok(()) +} + +fn eligible_weight( + selector: &ControllerSelector, + operation: OperationKind, + controllers: &[ControllerDescriptor], +) -> Result { + let mut total = 0_u64; + for controller in controllers { + if selector.matches_controller(controller)? && controller.scope().allows(operation) { + total = total + .checked_add(u64::from(controller.weight().get())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "eligible controller weight", + })?; + } + } + Ok(total) +} + +fn sort_providers( + providers: Vec, +) -> Result, IdentityError> { + let mut keyed = providers + .into_iter() + .map(|provider| Ok((provider.id()?, provider))) + .collect::, IdentityError>>()?; + keyed.sort_unstable_by(|left, right| compare_digests(left.0.as_digest(), right.0.as_digest())); + let providers = keyed + .into_iter() + .map(|(_identifier, provider)| provider) + .collect::>(); + validate_sorted_providers(&providers)?; + Ok(providers) +} + +fn validate_sorted_providers(providers: &[ProviderDescriptor]) -> Result<(), IdentityError> { + for (index, provider) in providers.iter().enumerate() { + if providers[..index] + .iter() + .any(|prior| prior.signing_key() == provider.signing_key()) + { + return Err(IdentityError::DuplicateSigningKey); + } + } + for pair in providers.windows(2) { + let left = pair[0].id()?; + let right = pair[1].id()?; + match compare_digests(left.as_digest(), right.as_digest()) { + Ordering::Equal => { + return Err(IdentityError::DuplicateElement { + resource: "provider policy providers", + }); + } + Ordering::Greater => return Err(IdentityError::NonCanonical), + Ordering::Less => {} + } + } + Ok(()) +} diff --git a/protocols/krikos-identity/src/presence.rs b/protocols/krikos-identity/src/presence.rs new file mode 100644 index 00000000000..2887d4e8ea1 --- /dev/null +++ b/protocols/krikos-identity/src/presence.rs @@ -0,0 +1,447 @@ +//! Short-lived, exact-context device-presence challenge responses. + +use std::fmt; + +use krikos_base::{PublicKey as Ed25519PublicKey, Signature as Ed25519Signature}; +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + AccountId, ApplicationAuthorizationView, ApplicationDeviceStatus, CanonicalWire, CheckpointId, + DeviceId, Digest, Extensions, HashAlgorithm, IdentityError, ProtocolSignature, ProtocolVersion, + SigningPublicKey, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_ACCOUNT_EVENT_BYTES, MAX_FUTURE_CLOCK_SKEW, MAX_PRESENCE_LIFETIME}, +}; + +const PRESENCE_SIGNATURE_DOMAIN: &[u8] = b"KRIKOS-ID/device-presence-signature/v1"; +const PRESENCE_PROOF_ID_CONTEXT: &str = "KRIKOS-ID/device-presence-proof-id/v1"; + +macro_rules! nonzero_bytes { + ($name:ident, $resource:literal, $debug:literal) => { + #[doc = $resource] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] + pub struct $name([u8; 32]); + + impl $name { + /// Validate exact nonzero bytes. + pub fn new(bytes: [u8; 32]) -> Result { + if bytes == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: $resource, + }); + } + Ok(Self(bytes)) + } + + /// Borrow the exact bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str($debug) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(<[u8; 32]>::deserialize(deserializer)?).map_err(de::Error::custom) + } + } + + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +nonzero_bytes!( + PresenceVerifierChallenge, + "presence verifier challenge", + "PresenceVerifierChallenge()" +); +nonzero_bytes!( + PresenceSessionId, + "presence session identifier", + "PresenceSessionId()" +); + +fn validate_lifetime(issued_at: Timestamp, expires_at: Timestamp) -> Result<(), IdentityError> { + let lifetime = expires_at + .as_unix_millis() + .checked_sub(issued_at.as_unix_millis()) + .ok_or(IdentityError::InvalidRelationship { + resource: "presence proof validity interval", + })?; + if lifetime == 0 { + return Err(IdentityError::ZeroValue { + resource: "presence proof lifetime", + }); + } + if u128::from(lifetime) > MAX_PRESENCE_LIFETIME.as_millis() { + return Err(IdentityError::LimitExceeded { + resource: "presence proof lifetime milliseconds", + actual: usize::try_from(lifetime).unwrap_or(usize::MAX), + maximum: usize::try_from(MAX_PRESENCE_LIFETIME.as_millis()).unwrap_or(usize::MAX), + }); + } + Ok(()) +} + +/// Verifier-generated complete context to be signed by one exact device key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DevicePresenceChallenge { + protocol_version: ProtocolVersion, + account_id: AccountId, + device_id: DeviceId, + verifier_challenge: PresenceVerifierChallenge, + session_id: PresenceSessionId, + transcript_binding: Digest, + checkpoint_id: CheckpointId, + issued_at: Timestamp, + expires_at: Timestamp, + signing_key: SigningPublicKey, + extensions: Extensions, +} + +impl DevicePresenceChallenge { + /// Construct a complete presence challenge with at most five minutes of validity. + #[allow(clippy::too_many_arguments)] + pub fn new( + account_id: AccountId, + device_id: DeviceId, + verifier_challenge: PresenceVerifierChallenge, + session_id: PresenceSessionId, + transcript_binding: Digest, + checkpoint_id: CheckpointId, + issued_at: Timestamp, + expires_at: Timestamp, + signing_key: SigningPublicKey, + extensions: Extensions, + ) -> Result { + validate_lifetime(issued_at, expires_at)?; + if transcript_binding.as_bytes() == &[0; 32] { + return Err(IdentityError::ZeroValue { + resource: "presence transcript binding", + }); + } + extensions.validate_critical(&[])?; + let challenge = Self { + protocol_version: ProtocolVersion::V1, + account_id, + device_id, + verifier_challenge, + session_id, + transcript_binding, + checkpoint_id, + issued_at, + expires_at, + signing_key, + extensions, + }; + let encoded_len = encode_wire(&challenge)?.len(); + if encoded_len > MAX_ACCOUNT_EVENT_BYTES { + return Err(IdentityError::limit( + "device presence challenge bytes", + encoded_len, + MAX_ACCOUNT_EVENT_BYTES, + )); + } + Ok(challenge) + } + + /// Exact domain-separated bytes signed by the named device key. + pub fn signing_bytes(&self) -> Result, IdentityError> { + let body = self.to_canonical_bytes()?; + let capacity = PRESENCE_SIGNATURE_DOMAIN + .len() + .checked_add(1) + .and_then(|length| length.checked_add(body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "presence signature message bytes", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(PRESENCE_SIGNATURE_DOMAIN); + message.push(0); + message.extend_from_slice(&body); + Ok(message) + } + + /// Account whose known checkpoint supplies authorization. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Device expected to sign. + pub const fn device_id(&self) -> DeviceId { + self.device_id + } + + /// Fresh verifier challenge. + pub const fn verifier_challenge(&self) -> PresenceVerifierChallenge { + self.verifier_challenge + } + + /// Single authenticated session identifier. + pub const fn session_id(&self) -> PresenceSessionId { + self.session_id + } + + /// Exact higher-level connection transcript binding. + pub const fn transcript_binding(&self) -> Digest { + self.transcript_binding + } + + /// Exact locally known authorization checkpoint. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Explicit challenge issue time. + pub const fn issued_at(&self) -> Timestamp { + self.issued_at + } + + /// Explicit proof expiry time. + pub const fn expires_at(&self) -> Timestamp { + self.expires_at + } + + /// Exact authorized application-signing key expected to respond. + pub const fn signing_key(&self) -> SigningPublicKey { + self.signing_key + } +} + +impl<'de> Deserialize<'de> for DevicePresenceChallenge { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let ( + protocol_version, + account_id, + device_id, + verifier_challenge, + session_id, + transcript_binding, + checkpoint_id, + issued_at, + expires_at, + signing_key, + extensions, + ) = <( + ProtocolVersion, + AccountId, + DeviceId, + PresenceVerifierChallenge, + PresenceSessionId, + Digest, + CheckpointId, + Timestamp, + Timestamp, + SigningPublicKey, + Extensions, + )>::deserialize(deserializer)?; + if protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: protocol_version.get(), + })); + } + Self::new( + account_id, + device_id, + verifier_challenge, + session_id, + transcript_binding, + checkpoint_id, + issued_at, + expires_at, + signing_key, + extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for DevicePresenceChallenge { + const RESOURCE: &'static str = "device presence challenge bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Complete signed response to one exact presence challenge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PresenceProof { + challenge: DevicePresenceChallenge, + signature: ProtocolSignature, +} + +impl PresenceProof { + /// Construct a signed response. Cryptographic and state checks occur during verification. + pub fn new( + challenge: DevicePresenceChallenge, + signature: ProtocolSignature, + ) -> Result { + let proof = Self { + challenge, + signature, + }; + let encoded_len = encode_wire(&proof)?.len(); + if encoded_len > MAX_ACCOUNT_EVENT_BYTES { + return Err(IdentityError::limit( + "device presence proof bytes", + encoded_len, + MAX_ACCOUNT_EVENT_BYTES, + )); + } + Ok(proof) + } + + /// Exact challenge that was signed. + pub const fn challenge(&self) -> &DevicePresenceChallenge { + &self.challenge + } + + /// Exact Ed25519 response signature. + pub const fn signature(&self) -> ProtocolSignature { + self.signature + } + + /// Domain-separated identifier of the complete proof. + pub fn proof_id(&self) -> Result { + Ok(PresenceProofId(Digest::new( + HashAlgorithm::Blake3_256, + blake3::derive_key(PRESENCE_PROOF_ID_CONTEXT, &self.to_canonical_bytes()?), + ))) + } +} + +impl CanonicalCodec for PresenceProof { + const RESOURCE: &'static str = "device presence proof bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Domain-separated identifier of a complete signed presence proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct PresenceProofId(Digest); + +impl PresenceProofId { + /// Borrow the tagged digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } +} + +impl CanonicalCodec for PresenceProofId { + const RESOURCE: &'static str = "device presence proof identifier bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Verify exact-session liveness under one already-authenticated known checkpoint. +/// +/// Success proves possession of the exact active device key in the caller-supplied checkpoint +/// view. It does not establish that checkpoint's global freshness, network reachability outside +/// this session, or continued authorization after the proof expires. +pub fn verify_presence_proof( + proof: &PresenceProof, + expected_challenge: &DevicePresenceChallenge, + now: Timestamp, + view: &impl ApplicationAuthorizationView, +) -> Result { + if proof.challenge != *expected_challenge { + return Err(IdentityError::InvalidRelationship { + resource: "presence expected challenge context", + }); + } + let future_skew = u64::try_from(MAX_FUTURE_CLOCK_SKEW.as_millis()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "presence future clock skew milliseconds", + } + })?; + let maximum_issue_time = now.checked_add(crate::DurationMillis::new(future_skew))?; + if proof.challenge.issued_at > maximum_issue_time { + return Err(IdentityError::InvalidRelationship { + resource: "presence proof future issue time", + }); + } + if now > proof.challenge.expires_at { + return Err(IdentityError::StaleEvidence); + } + + let context = view.authorization_context(); + if proof.challenge.account_id != context.account_id() { + return Err(IdentityError::AccountMismatch); + } + if proof.challenge.checkpoint_id != context.checkpoint_id() { + return Err(IdentityError::InvalidRelationship { + resource: "presence authorization checkpoint", + }); + } + match view.device_status(proof.challenge.device_id) { + ApplicationDeviceStatus::Unknown => return Err(IdentityError::DeviceNotAuthorized), + ApplicationDeviceStatus::Active => {} + ApplicationDeviceStatus::Suspended => return Err(IdentityError::DeviceSuspended), + ApplicationDeviceStatus::Revoked => return Err(IdentityError::DeviceRevoked), + } + let authorization = view + .device_authorization(proof.challenge.device_id) + .ok_or(IdentityError::DeviceNotAuthorized)?; + if authorization.device_id() != proof.challenge.device_id { + return Err(IdentityError::InvalidIdentifier { + resource: "presence authorized device", + }); + } + if authorization.authorization_epoch() > context.epoch() { + return Err(IdentityError::InvalidEpoch); + } + if authorization.descriptor().application_signing_key() != proof.challenge.signing_key { + return Err(IdentityError::InvalidRelationship { + resource: "presence exact device signing key", + }); + } + + let public_key = Ed25519PublicKey::from_bytes(proof.challenge.signing_key.as_bytes()) + .map_err(|_| IdentityError::InvalidSignature)?; + let signature = Ed25519Signature::try_from(proof.signature.as_bytes().as_slice()) + .map_err(|_| IdentityError::InvalidSignature)?; + public_key + .verify(&proof.challenge.signing_bytes()?, &signature) + .map_err(|_| IdentityError::InvalidSignature)?; + proof.proof_id() +} diff --git a/protocols/krikos-identity/src/privacy.rs b/protocols/krikos-identity/src/privacy.rs new file mode 100644 index 00000000000..2364800425b --- /dev/null +++ b/protocols/krikos-identity/src/privacy.rs @@ -0,0 +1,2574 @@ +//! Encrypted private artifacts and privacy-preserving identity primitives. + +use std::fmt; + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + Key, XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, Payload}, +}; +use rand_core::TryCryptoRng; +use serde::{Deserialize, Deserializer, Serialize, de}; +use zeroize::Zeroizing; + +use crate::{ + AccountGenesis, AccountId, AccountOperation, AccountState, AdmissionEvidence, AeadAlgorithm, + AlgorithmSignature, ApplicationId, ApplyDisposition, AuthorizedEvent, CanonicalWire, + CheckpointId, ControllerApprovalBody, ControllerDescriptor, Digest, Epoch, EventBody, + Extensions, HashAlgorithm, IdentityError, KdfAlgorithm, OperationKind, ProtocolVersion, + ProviderId, SignedCheckpoint, SigningPublicKey, Timestamp, VerifiedCheckpoint, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{ + MAX_ACTIVE_CRYPTO_SUITES, MAX_APPLICATION_BACKUP_DATA_BYTES, + MAX_CREDENTIAL_CLAIM_NAME_BYTES, MAX_CREDENTIAL_CLAIM_VALUE_BYTES, MAX_CREDENTIAL_CLAIMS, + MAX_HISTORY_PAGE_EVENTS, MAX_OFFLINE_SIGNING_REQUEST_BYTES, MAX_PORTABLE_CREDENTIAL_BYTES, + MAX_PRIVATE_BACKUP_BYTES, MAX_PRIVATE_LABEL_BYTES, MAX_PRIVATE_METADATA_BYTES, + MAX_RELYING_PARTY_CONTEXT_BYTES, + }, + schema::{BoundedBytes, BoundedVec}, + verify_checkpoint, +}; + +const PRIVATE_METADATA_KEY_BYTES: usize = 32; +const PRIVATE_ARTIFACT_SALT_BYTES: usize = 16; +const PRIVATE_ARTIFACT_NONCE_BYTES: usize = 24; +const PRIVATE_ARTIFACT_CONTENT_KEY_BYTES: usize = 32; +const PRIVATE_ARTIFACT_TAG_BYTES: usize = 16; +const PRIVATE_ARTIFACT_WRAPPED_KEY_BYTES: usize = + PRIVATE_ARTIFACT_CONTENT_KEY_BYTES + PRIVATE_ARTIFACT_TAG_BYTES; +const PRIVATE_ARTIFACT_HEADER_RESERVE_BYTES: usize = 1024; +const MAX_PRIVATE_METADATA_PLAINTEXT_BYTES: usize = + MAX_PRIVATE_METADATA_BYTES - PRIVATE_ARTIFACT_HEADER_RESERVE_BYTES; +const PRIVATE_METADATA_KIND_CODE: u16 = 1; +const PRIVATE_BACKUP_KIND_CODE: u16 = 2; +const PRIVATE_METADATA_KDF_CONTEXT: &str = "KRIKOS-ID/private-metadata-kek/v1"; +const PRIVATE_BACKUP_ARGON2ID_CODE: u16 = 1; +const PRIVATE_BACKUP_ARGON2_VERSION: u32 = 0x13; +const PRIVATE_BACKUP_ARGON2_MEMORY_KIB: u32 = 19_456; +const PRIVATE_BACKUP_ARGON2_ITERATIONS: u32 = 2; +const PRIVATE_BACKUP_ARGON2_LANES: u32 = 1; +const PRIVATE_BACKUP_ARGON2_OUTPUT_BYTES: u32 = 32; +const PRIVATE_BACKUP_PASSPHRASE_BYTES: usize = 1024; +const PRIVATE_BACKUP_HEADER_RESERVE_BYTES: usize = 4 * 1024; +const MAX_PRIVATE_BACKUP_PLAINTEXT_BYTES: usize = + MAX_PRIVATE_BACKUP_BYTES - PRIVATE_BACKUP_HEADER_RESERVE_BYTES; +const PRIVATE_ARTIFACT_WRAP_DOMAIN: &[u8] = b"KRIKOS-ID/private-artifact-key-wrap/v1"; +const PRIVATE_ARTIFACT_CONTENT_DOMAIN: &[u8] = b"KRIKOS-ID/private-artifact-content/v1"; +const RELATIONSHIP_LABEL_COMMITMENT_CODE: u16 = 1; +const RELATIONSHIP_LABEL_COMMITMENT_DOMAIN: &[u8] = b"KRIKOS-ID/relationship-label/v1"; +const LOOKUP_HANDLE_DOMAIN: &[u8] = b"KRIKOS-ID/private-checkpoint-lookup/v1"; +const PAIRWISE_IDENTIFIER_DOMAIN: &[u8] = b"KRIKOS-ID/pairwise-identifier/v1"; +const PORTABLE_CREDENTIAL_SIGNING_DOMAIN: &[u8] = b"KRIKOS-ID/portable-credential/v1"; + +/// Exact public context authenticated by a private artifact envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PrivateArtifactContext { + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + application_id: Option, + generation: u64, + extensions: Extensions, +} + +impl PrivateArtifactContext { + /// Construct an exact account/checkpoint/application artifact context. + pub fn try_new( + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + application_id: Option, + generation: u64, + extensions: Extensions, + ) -> Result { + extensions.validate_critical(&[])?; + Ok(Self { + account_id, + checkpoint_id, + account_epoch, + application_id, + generation, + extensions, + }) + } + + /// Account that owns the private artifact. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact account checkpoint authenticated with the ciphertext. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Exact account epoch authenticated with the ciphertext. + pub const fn account_epoch(&self) -> Epoch { + self.account_epoch + } + + /// Optional application namespace owning the private artifact. + pub const fn application_id(&self) -> Option { + self.application_id + } + + /// Caller-managed rotation generation authenticated with the ciphertext. + pub const fn generation(&self) -> u64 { + self.generation + } +} + +impl<'de> Deserialize<'de> for PrivateArtifactContext { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + application_id: Option, + generation: u64, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::try_new( + wire.account_id, + wire.checkpoint_id, + wire.account_epoch, + wire.application_id, + wire.generation, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for PrivateArtifactContext { + const RESOURCE: &'static str = "private artifact context bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// High-entropy metadata key which erases its bytes when dropped. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +pub struct PrivateMetadataKey(Zeroizing<[u8; PRIVATE_METADATA_KEY_BYTES]>); + +impl PrivateMetadataKey { + /// Take ownership of one nonzero 256-bit metadata key. + pub fn try_new(bytes: [u8; PRIVATE_METADATA_KEY_BYTES]) -> Result { + if bytes == [0; PRIVATE_METADATA_KEY_BYTES] { + return Err(IdentityError::ZeroValue { + resource: "private metadata key", + }); + } + Ok(Self(Zeroizing::new(bytes))) + } + + fn as_bytes(&self) -> &[u8; PRIVATE_METADATA_KEY_BYTES] { + &self.0 + } +} + +impl fmt::Debug for PrivateMetadataKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PrivateMetadataKey()") + } +} + +/// Bounded private metadata plaintext which erases its bytes when dropped. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +#[derive(PartialEq, Eq)] +pub struct PrivateMetadata(Zeroizing>); + +impl PrivateMetadata { + /// Take ownership of nonempty metadata which fits in one bounded encrypted envelope. + pub fn try_new(bytes: Vec) -> Result { + if bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "private metadata plaintext", + }); + } + if bytes.len() > MAX_PRIVATE_METADATA_PLAINTEXT_BYTES { + return Err(IdentityError::limit( + "private metadata plaintext", + bytes.len(), + MAX_PRIVATE_METADATA_PLAINTEXT_BYTES, + )); + } + Ok(Self(Zeroizing::new(bytes))) + } + + /// Borrow the decrypted metadata bytes while retaining zeroizing ownership. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl fmt::Debug for PrivateMetadata { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PrivateMetadata()") + } +} + +/// Canonical versioned envelope containing only encrypted private metadata. +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct PrivateMetadataEnvelope { + protocol_version: ProtocolVersion, + artifact_kind_code: u16, + kdf: KdfAlgorithm, + wrapping_aead: AeadAlgorithm, + content_aead: AeadAlgorithm, + context: PrivateArtifactContext, + salt: [u8; PRIVATE_ARTIFACT_SALT_BYTES], + wrapping_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + wrapped_content_key: BoundedBytes, + ciphertext: BoundedBytes, + extensions: Extensions, +} + +impl PrivateMetadataEnvelope { + /// Encrypt metadata using fallible operating-system entropy. + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] + pub fn seal( + context: PrivateArtifactContext, + key: &PrivateMetadataKey, + plaintext: &PrivateMetadata, + ) -> Result { + let mut salt = [0; PRIVATE_ARTIFACT_SALT_BYTES]; + let mut wrapping_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_key = Zeroizing::new([0; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES]); + getrandom::fill(&mut salt).map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(&mut wrapping_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(&mut content_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(content_key.as_mut()).map_err(|_| IdentityError::EntropyUnavailable)?; + Self::seal_with_randomness( + context, + key, + plaintext, + salt, + wrapping_nonce, + content_nonce, + content_key, + ) + } + + /// Encrypt metadata using injected fallible cryptographic entropy for vectors and tests. + pub fn seal_with_rng( + context: PrivateArtifactContext, + key: &PrivateMetadataKey, + plaintext: &PrivateMetadata, + rng: &mut impl TryCryptoRng, + ) -> Result { + let mut salt = [0; PRIVATE_ARTIFACT_SALT_BYTES]; + let mut wrapping_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_key = Zeroizing::new([0; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES]); + rng.try_fill_bytes(&mut salt) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(&mut wrapping_nonce) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(&mut content_nonce) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(content_key.as_mut()) + .map_err(|_| IdentityError::EntropyUnavailable)?; + Self::seal_with_randomness( + context, + key, + plaintext, + salt, + wrapping_nonce, + content_nonce, + content_key, + ) + } + + #[allow(clippy::too_many_arguments)] + fn seal_with_randomness( + context: PrivateArtifactContext, + key: &PrivateMetadataKey, + plaintext: &PrivateMetadata, + salt: [u8; PRIVATE_ARTIFACT_SALT_BYTES], + wrapping_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_key: Zeroizing<[u8; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES]>, + ) -> Result { + if salt == [0; PRIVATE_ARTIFACT_SALT_BYTES] + || wrapping_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + || content_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + || content_key.as_ref() == [0; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES] + { + return Err(IdentityError::EntropyUnavailable); + } + let mut envelope = Self { + protocol_version: ProtocolVersion::V1, + artifact_kind_code: PRIVATE_METADATA_KIND_CODE, + kdf: KdfAlgorithm::Blake3DeriveKey, + wrapping_aead: AeadAlgorithm::XChaCha20Poly1305, + content_aead: AeadAlgorithm::XChaCha20Poly1305, + context, + salt, + wrapping_nonce, + content_nonce, + wrapped_content_key: BoundedBytes::new("wrapped private content key", Vec::new())?, + ciphertext: BoundedBytes::new("private metadata ciphertext", Vec::new())?, + extensions: Extensions::default(), + }; + let wrapping_key = derive_metadata_wrapping_key(key, &envelope.salt); + let wrapping_aad = envelope.wrapping_aad()?; + let wrapping_cipher = XChaCha20Poly1305::new(&Key::from(*wrapping_key)); + let wrapped_content_key = wrapping_cipher + .encrypt( + &XNonce::from(envelope.wrapping_nonce), + Payload { + msg: content_key.as_ref(), + aad: &wrapping_aad, + }, + ) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "private content-key wrapping", + })?; + envelope.wrapped_content_key = + BoundedBytes::new("wrapped private content key", wrapped_content_key)?; + + let content_aad = envelope.content_aad()?; + let content_cipher = XChaCha20Poly1305::new(&Key::from(*content_key)); + let ciphertext = content_cipher + .encrypt( + &XNonce::from(envelope.content_nonce), + Payload { + msg: plaintext.as_bytes(), + aad: &content_aad, + }, + ) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "private metadata encryption", + })?; + envelope.ciphertext = BoundedBytes::new("private metadata ciphertext", ciphertext)?; + envelope.validate()?; + let encoded_len = encode_wire(&envelope)?.len(); + if encoded_len > MAX_PRIVATE_METADATA_BYTES { + return Err(IdentityError::limit( + "private metadata envelope bytes", + encoded_len, + MAX_PRIVATE_METADATA_BYTES, + )); + } + Ok(envelope) + } + + /// Authenticate and decrypt the metadata without distinguishing wrong keys from corruption. + pub fn open(&self, key: &PrivateMetadataKey) -> Result { + self.validate()?; + let wrapping_key = derive_metadata_wrapping_key(key, &self.salt); + let wrapping_aad = self.wrapping_aad()?; + let wrapping_cipher = XChaCha20Poly1305::new(&Key::from(*wrapping_key)); + let content_key = wrapping_cipher + .decrypt( + &XNonce::from(self.wrapping_nonce), + Payload { + msg: self.wrapped_content_key.as_slice(), + aad: &wrapping_aad, + }, + ) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + let content_key: [u8; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES] = content_key + .try_into() + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + let content_key = Zeroizing::new(content_key); + let content_aad = self.content_aad()?; + let content_cipher = XChaCha20Poly1305::new(&Key::from(*content_key)); + let plaintext = content_cipher + .decrypt( + &XNonce::from(self.content_nonce), + Payload { + msg: self.ciphertext.as_slice(), + aad: &content_aad, + }, + ) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + PrivateMetadata::try_new(plaintext) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed) + } + + /// Exact public context authenticated by both key wrapping and content encryption. + pub const fn context(&self) -> &PrivateArtifactContext { + &self.context + } + + fn validate(&self) -> Result<(), IdentityError> { + if self.protocol_version != ProtocolVersion::V1 { + return Err(IdentityError::UnsupportedVersion { + version: self.protocol_version.get(), + }); + } + if self.artifact_kind_code != PRIVATE_METADATA_KIND_CODE { + return Err(IdentityError::UnsupportedCodepoint { + registry: "private artifact kind", + code: self.artifact_kind_code, + }); + } + if self.kdf != KdfAlgorithm::Blake3DeriveKey + || self.wrapping_aead != AeadAlgorithm::XChaCha20Poly1305 + || self.content_aead != AeadAlgorithm::XChaCha20Poly1305 + { + return Err(IdentityError::InvalidRelationship { + resource: "private metadata cryptographic profile", + }); + } + if self.salt == [0; PRIVATE_ARTIFACT_SALT_BYTES] + || self.wrapping_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + || self.content_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + { + return Err(IdentityError::ZeroValue { + resource: "private artifact salt or nonce", + }); + } + if self.wrapped_content_key.len() != PRIVATE_ARTIFACT_WRAPPED_KEY_BYTES + || self.ciphertext.len() <= PRIVATE_ARTIFACT_TAG_BYTES + { + return Err(IdentityError::InvalidEncoding); + } + self.extensions.validate_critical(&[]) + } + + fn header_bytes(&self) -> Result, IdentityError> { + encode_wire(&( + self.protocol_version, + self.artifact_kind_code, + self.kdf, + self.wrapping_aead, + self.content_aead, + &self.context, + self.salt, + self.wrapping_nonce, + self.content_nonce, + &self.extensions, + )) + } + + fn wrapping_aad(&self) -> Result, IdentityError> { + domain_message(PRIVATE_ARTIFACT_WRAP_DOMAIN, &self.header_bytes()?) + } + + fn content_aad(&self) -> Result, IdentityError> { + let body = encode_wire(&(self.header_bytes()?, self.wrapped_content_key.as_slice()))?; + domain_message(PRIVATE_ARTIFACT_CONTENT_DOMAIN, &body) + } +} + +impl fmt::Debug for PrivateMetadataEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateMetadataEnvelope") + .field("context", &self.context) + .field("ciphertext_bytes", &self.ciphertext.len()) + .finish_non_exhaustive() + } +} + +impl<'de> Deserialize<'de> for PrivateMetadataEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + artifact_kind_code: u16, + kdf: KdfAlgorithm, + wrapping_aead: AeadAlgorithm, + content_aead: AeadAlgorithm, + context: PrivateArtifactContext, + salt: [u8; PRIVATE_ARTIFACT_SALT_BYTES], + wrapping_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + wrapped_content_key: BoundedBytes, + ciphertext: BoundedBytes, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + let envelope = Self { + protocol_version: wire.protocol_version, + artifact_kind_code: wire.artifact_kind_code, + kdf: wire.kdf, + wrapping_aead: wire.wrapping_aead, + content_aead: wire.content_aead, + context: wire.context, + salt: wire.salt, + wrapping_nonce: wire.wrapping_nonce, + content_nonce: wire.content_nonce, + wrapped_content_key: wire.wrapped_content_key, + ciphertext: wire.ciphertext, + extensions: wire.extensions, + }; + envelope.validate().map_err(de::Error::custom)?; + Ok(envelope) + } +} + +impl CanonicalCodec for PrivateMetadataEnvelope { + const RESOURCE: &'static str = "private metadata envelope bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PRIVATE_METADATA_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Fresh high-entropy blinding material for a single public commitment. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +/// +/// ```compile_fail +/// use krikos_identity::BlindingSecret; +/// fn require_clone() {} +/// require_clone::(); +/// ``` +#[derive(PartialEq, Eq)] +pub struct BlindingSecret(Zeroizing<[u8; 32]>); + +impl BlindingSecret { + /// Take ownership of one nonzero 256-bit blinding. + pub fn try_new(bytes: [u8; 32]) -> Result { + Ok(Self(nonzero_secret(bytes, "blinding secret")?)) + } + + /// Generate fresh blinding material from fallible operating-system entropy. + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] + pub fn generate() -> Result { + Ok(Self(os_secret()?)) + } + + /// Generate deterministic or injected blinding material for tests and vectors. + pub fn generate_with_rng(rng: &mut impl TryCryptoRng) -> Result { + Ok(Self(rng_secret(rng)?)) + } + + pub(crate) fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for BlindingSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("BlindingSecret()") + } +} + +/// Bounded private relationship label used only before a blinded commitment is derived. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +pub struct PrivateLabel(Zeroizing>); + +impl PrivateLabel { + /// Take ownership of one nonempty private label. + pub fn try_new(bytes: Vec) -> Result { + if bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "private relationship label", + }); + } + if bytes.len() > MAX_PRIVATE_LABEL_BYTES { + return Err(IdentityError::limit( + "private relationship label", + bytes.len(), + MAX_PRIVATE_LABEL_BYTES, + )); + } + Ok(Self(Zeroizing::new(bytes))) + } + + fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl fmt::Debug for PrivateLabel { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PrivateLabel()") + } +} + +/// Public domain-separated commitment which reveals neither a private label nor its blinding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct BlindedCommitment { + protocol_version: ProtocolVersion, + purpose_code: u16, + digest: Digest, +} + +impl BlindedCommitment { + /// Commit to a private relationship label with one fresh 256-bit blinding. + pub fn relationship_label( + label: &PrivateLabel, + blinding: &BlindingSecret, + ) -> Result { + let digest = keyed_digest( + blinding.as_bytes(), + RELATIONSHIP_LABEL_COMMITMENT_DOMAIN, + label.as_bytes(), + )?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + purpose_code: RELATIONSHIP_LABEL_COMMITMENT_CODE, + digest, + }) + } + + /// Domain-separated public commitment digest. + pub const fn digest(self) -> Digest { + self.digest + } + + fn validate(&self) -> Result<(), IdentityError> { + if self.protocol_version != ProtocolVersion::V1 { + return Err(IdentityError::UnsupportedVersion { + version: self.protocol_version.get(), + }); + } + if self.purpose_code != RELATIONSHIP_LABEL_COMMITMENT_CODE { + return Err(IdentityError::UnsupportedCodepoint { + registry: "blinded commitment purpose", + code: self.purpose_code, + }); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for BlindedCommitment { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (protocol_version, purpose_code, digest) = + <(ProtocolVersion, u16, Digest)>::deserialize(deserializer)?; + let commitment = Self { + protocol_version, + purpose_code, + digest, + }; + commitment.validate().map_err(de::Error::custom)?; + Ok(commitment) + } +} + +impl CanonicalCodec for BlindedCommitment { + const RESOURCE: &'static str = "blinded private commitment bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Account-held secret used only to derive rotating private checkpoint lookup handles. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +pub struct LookupHandleSecret(Zeroizing<[u8; 32]>); + +impl LookupHandleSecret { + /// Take ownership of one nonzero 256-bit lookup secret. + pub fn try_new(bytes: [u8; 32]) -> Result { + Ok(Self(nonzero_secret(bytes, "private lookup-handle secret")?)) + } + + /// Generate a fresh lookup secret from fallible operating-system entropy. + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] + pub fn generate() -> Result { + Ok(Self(os_secret()?)) + } + + /// Generate an injected lookup secret for tests and vectors. + pub fn generate_with_rng(rng: &mut impl TryCryptoRng) -> Result { + Ok(Self(rng_secret(rng)?)) + } + + fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for LookupHandleSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("LookupHandleSecret()") + } +} + +/// Rotating opaque checkpoint lookup handle scoped to one provider and generation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PrivateCheckpointLookupHandle { + protocol_version: ProtocolVersion, + provider_id: ProviderId, + generation: u64, + handle: Digest, + extensions: Extensions, +} + +impl PrivateCheckpointLookupHandle { + /// Derive an opaque handle bound to the exact provider, hidden account, and generation. + pub fn derive( + secret: &LookupHandleSecret, + provider_id: ProviderId, + account_id: AccountId, + generation: u64, + ) -> Result { + if generation == 0 { + return Err(IdentityError::ZeroValue { + resource: "private lookup-handle generation", + }); + } + let payload = encode_wire(&(provider_id, account_id, generation))?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + provider_id, + generation, + handle: keyed_digest(secret.as_bytes(), LOOKUP_HANDLE_DOMAIN, &payload)?, + extensions: Extensions::default(), + }) + } + + /// Provider namespace to which this handle may be sent. + pub const fn provider_id(&self) -> ProviderId { + self.provider_id + } + + /// Caller-managed lookup generation. + pub const fn generation(&self) -> u64 { + self.generation + } + + /// Opaque domain-separated lookup digest. + pub const fn digest(&self) -> Digest { + self.handle + } + + fn validate(&self) -> Result<(), IdentityError> { + if self.protocol_version != ProtocolVersion::V1 { + return Err(IdentityError::UnsupportedVersion { + version: self.protocol_version.get(), + }); + } + if self.generation == 0 { + return Err(IdentityError::ZeroValue { + resource: "private lookup-handle generation", + }); + } + self.extensions.validate_critical(&[]) + } +} + +impl<'de> Deserialize<'de> for PrivateCheckpointLookupHandle { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + provider_id: ProviderId, + generation: u64, + handle: Digest, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + let handle = Self { + protocol_version: wire.protocol_version, + provider_id: wire.provider_id, + generation: wire.generation, + handle: wire.handle, + extensions: wire.extensions, + }; + handle.validate().map_err(de::Error::custom)?; + Ok(handle) + } +} + +impl CanonicalCodec for PrivateCheckpointLookupHandle { + const RESOURCE: &'static str = "private checkpoint lookup handle bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Account-held secret used only for pairwise relying-party identifiers. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +pub struct PairwiseMasterSecret(Zeroizing<[u8; 32]>); + +impl PairwiseMasterSecret { + /// Take ownership of one nonzero 256-bit pairwise master secret. + pub fn try_new(bytes: [u8; 32]) -> Result { + Ok(Self(nonzero_secret(bytes, "pairwise master secret")?)) + } + + /// Generate a pairwise master secret from fallible operating-system entropy. + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] + pub fn generate() -> Result { + Ok(Self(os_secret()?)) + } + + /// Generate an injected pairwise master secret for tests and vectors. + pub fn generate_with_rng(rng: &mut impl TryCryptoRng) -> Result { + Ok(Self(rng_secret(rng)?)) + } + + fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for PairwiseMasterSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PairwiseMasterSecret()") + } +} + +/// Lowercase ASCII DNS-style relying-party namespace. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct RelyingPartyContext(String); + +impl RelyingPartyContext { + /// Normalize and validate one bounded ASCII DNS-style relying-party context. + pub fn try_new(value: &str) -> Result { + let normalized = normalize_dns_context(value, "relying-party context")?; + Ok(Self(normalized)) + } + + /// Canonical lowercase relying-party context. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for RelyingPartyContext { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::try_new(&String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for RelyingPartyContext { + const RESOURCE: &'static str = "relying-party context bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Stable pseudonymous identifier unlinkable across normalized relying-party contexts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PairwiseIdentifier(Digest); + +impl PairwiseIdentifier { + /// Derive one account- and relying-party-bound pairwise identifier. + pub fn derive( + master: &PairwiseMasterSecret, + account_id: AccountId, + context: &RelyingPartyContext, + ) -> Result { + let payload = encode_wire(&(account_id, context))?; + Ok(Self(keyed_digest( + master.as_bytes(), + PAIRWISE_IDENTIFIER_DOMAIN, + &payload, + )?)) + } + + /// Public pairwise digest. + pub const fn digest(self) -> Digest { + self.0 + } +} + +impl CanonicalCodec for PairwiseIdentifier { + const RESOURCE: &'static str = "pairwise identifier bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// One explicitly disclosed, bounded portable-credential claim. +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct CredentialClaim { + name: String, + value: BoundedBytes, +} + +impl CredentialClaim { + /// Construct one normalized claim whose value is intentionally disclosed by this export. + pub fn try_new(name: &str, value: Vec) -> Result { + let name = normalize_claim_name(name)?; + if value.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "portable credential claim value", + }); + } + Ok(Self { + name, + value: BoundedBytes::new("portable credential claim value", value)?, + }) + } + + /// Canonical lowercase claim name. + pub fn name(&self) -> &str { + &self.name + } + + /// Explicitly disclosed claim value. + pub fn value(&self) -> &[u8] { + self.value.as_slice() + } +} + +impl fmt::Debug for CredentialClaim { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CredentialClaim") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +impl<'de> Deserialize<'de> for CredentialClaim { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + name: String, + value: BoundedBytes, + } + + let wire = Wire::deserialize(deserializer)?; + Self::try_new(&wire.name, wire.value.into_vec()).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for CredentialClaim { + const RESOURCE: &'static str = "portable credential claim bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Exact domain of an offline signing request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SigningPurpose { + /// Sign an explicitly selected portable credential export. + PortableCredential, + /// Sign an exact canonical account-approval body. + AccountApproval, +} + +impl SigningPurpose { + const fn code(self) -> u16 { + match self { + Self::PortableCredential => 1, + Self::AccountApproval => 2, + } + } + + fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::PortableCredential), + 2 => Ok(Self::AccountApproval), + code => Err(IdentityError::UnsupportedCodepoint { + registry: "offline signing purpose", + code, + }), + } + } +} + +impl Serialize for SigningPurpose { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SigningPurpose { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_code(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// Bounded exact bytes and derived public context presented to an offline signer. +/// +/// Arbitrary metadata-plus-bytes construction is intentionally unavailable: +/// +/// ```compile_fail +/// use krikos_identity::CanonicalSigningRequest; +/// let _ = CanonicalSigningRequest::try_new(); +/// ``` +pub struct CanonicalSigningRequest { + purpose: SigningPurpose, + account_id: AccountId, + signer_account_id: AccountId, + account_epoch: Epoch, + operation_kind: Option, + expected_signing_key: SigningPublicKey, + canonical_message: BoundedBytes, +} + +impl CanonicalSigningRequest { + /// Construct a request for the exact selectively disclosed credential body. + pub fn for_portable_credential(body: &PortableCredentialBody) -> Result { + Self::from_validated_parts( + SigningPurpose::PortableCredential, + body.account_id(), + body.issuer_account_id(), + body.account_epoch(), + None, + body.issuer_signing_key(), + body.signing_bytes()?, + ) + } + + /// Construct a request for one exact final account-event controller approval. + /// + /// The event, admission evidence, approval subject, controller identifier, immutable scope, + /// signing key, and signer-visible context are validated before any request is returned. + pub fn for_account_approval( + event_body: &EventBody, + admission_evidence: &AdmissionEvidence, + approval_body: &ControllerApprovalBody, + controller: &ControllerDescriptor, + ) -> Result { + let event_id = admission_evidence.event_id_for_body(event_body)?; + let admission_evidence_id = admission_evidence.admission_evidence_id()?; + if approval_body.event_subject() != Some((event_id, admission_evidence_id)) { + return Err(IdentityError::InvalidRelationship { + resource: "offline account approval subject", + }); + } + if approval_body.controller_id() != controller.id()? { + return Err(IdentityError::InvalidRelationship { + resource: "offline account approval controller", + }); + } + let operation_kind = event_body.operation().kind(); + if !controller.scope().allows(operation_kind) { + return Err(IdentityError::IneligibleController); + } + if let AccountOperation::BeginRecovery(begin) = event_body.operation() + && admission_evidence.preceding_checkpoint() + != begin.proposal().plan().prior_checkpoint_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "offline begin recovery admission checkpoint", + }); + } + + Self::from_validated_parts( + SigningPurpose::AccountApproval, + event_body.account_id(), + event_body.account_id(), + event_body.resulting_epoch(), + Some(operation_kind), + controller.signing_key(), + approval_body.to_canonical_bytes()?, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_validated_parts( + purpose: SigningPurpose, + account_id: AccountId, + signer_account_id: AccountId, + account_epoch: Epoch, + operation_kind: Option, + expected_signing_key: SigningPublicKey, + canonical_message: Vec, + ) -> Result { + if canonical_message.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "offline canonical signing message", + }); + } + Ok(Self { + purpose, + account_id, + signer_account_id, + account_epoch, + operation_kind, + expected_signing_key, + canonical_message: BoundedBytes::new( + "offline canonical signing message", + canonical_message, + )?, + }) + } + + /// Exact purpose displayed by the offline signer. + pub const fn purpose(&self) -> SigningPurpose { + self.purpose + } + + /// Account whose credential or authority event is being signed. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Account that owns the requested signing key. + pub const fn signer_account_id(&self) -> AccountId { + self.signer_account_id + } + + /// Exact account epoch derived from the signed protocol object. + pub const fn account_epoch(&self) -> Epoch { + self.account_epoch + } + + /// Exact event operation, or `None` for a portable-credential export. + pub const fn operation_kind(&self) -> Option { + self.operation_kind + } + + /// Public key whose corresponding signer is requested. + pub const fn expected_signing_key(&self) -> SigningPublicKey { + self.expected_signing_key + } + + /// Exact canonical message bytes to sign, without hidden context or ambient authority. + pub fn canonical_message(&self) -> &[u8] { + self.canonical_message.as_slice() + } + + /// Verify a returned typed signature against the exact request bytes. + pub fn verify_response(&self, signature: &AlgorithmSignature) -> Result<(), IdentityError> { + verify_exact_signature( + self.expected_signing_key, + signature, + self.canonical_message(), + ) + } +} + +impl fmt::Debug for CanonicalSigningRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CanonicalSigningRequest") + .field("purpose", &self.purpose) + .field("account_id", &self.account_id) + .field("signer_account_id", &self.signer_account_id) + .field("account_epoch", &self.account_epoch) + .field("operation_kind", &self.operation_kind) + .field("expected_signing_key", &self.expected_signing_key) + .field("canonical_message", &"") + .finish() + } +} + +/// Pure boundary for an offline signer which receives only exact public request bytes. +pub trait OfflineSigner { + /// Sign the exact canonical message in `request` with its requested key. + fn sign_exact( + &self, + request: &CanonicalSigningRequest, + ) -> Result; +} + +/// Exact account-approval request presented to a hardware controller. +/// +/// ```compile_fail +/// use krikos_identity::HardwareApprovalRequest; +/// let _ = HardwareApprovalRequest::try_new(); +/// ``` +pub struct HardwareApprovalRequest { + signing_request: CanonicalSigningRequest, + operation_kind: OperationKind, +} + +impl HardwareApprovalRequest { + /// Construct a hardware request from one fully related account-approval object set. + pub fn for_account_approval( + event_body: &EventBody, + admission_evidence: &AdmissionEvidence, + approval_body: &ControllerApprovalBody, + controller: &ControllerDescriptor, + ) -> Result { + let operation_kind = event_body.operation().kind(); + Ok(Self { + signing_request: CanonicalSigningRequest::for_account_approval( + event_body, + admission_evidence, + approval_body, + controller, + )?, + operation_kind, + }) + } + + /// Protocol version displayed and accepted by the hardware boundary. + pub const fn protocol_version(&self) -> ProtocolVersion { + ProtocolVersion::V1 + } + + /// Exact account displayed by the hardware boundary. + pub const fn account_id(&self) -> AccountId { + self.signing_request.account_id() + } + + /// Exact resulting account epoch displayed by the hardware boundary. + pub const fn resulting_epoch(&self) -> Epoch { + self.signing_request.account_epoch() + } + + /// Exact account-operation kind displayed by the hardware boundary. + pub const fn operation_kind(&self) -> OperationKind { + self.operation_kind + } + + /// Public signing key whose corresponding hardware key is requested. + pub const fn expected_signing_key(&self) -> SigningPublicKey { + self.signing_request.expected_signing_key() + } + + /// Exact canonical approval bytes, without a private key or hidden storage/network capability. + pub fn canonical_message(&self) -> &[u8] { + self.signing_request.canonical_message() + } + + /// Verify a returned typed signature against the exact request bytes. + pub fn verify_response(&self, signature: &AlgorithmSignature) -> Result<(), IdentityError> { + self.signing_request.verify_response(signature) + } +} + +impl fmt::Debug for HardwareApprovalRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HardwareApprovalRequest") + .field("protocol_version", &self.protocol_version()) + .field("account_id", &self.account_id()) + .field("resulting_epoch", &self.resulting_epoch()) + .field("operation_kind", &self.operation_kind()) + .field("expected_signing_key", &self.expected_signing_key()) + .field("canonical_message", &"") + .finish() + } +} + +/// Pure hardware-controller boundary limited to one exact approval signature. +pub trait HardwareController { + /// Approve the exact canonical bytes after displaying the typed public context. + fn approve_exact( + &self, + request: &HardwareApprovalRequest, + ) -> Result; +} + +/// Exact selectively disclosed body signed for one portable credential export. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PortableCredentialBody { + protocol_version: ProtocolVersion, + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + subject_keys: BoundedVec, + issuer_account_id: AccountId, + issuer_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Timestamp, + claims: BoundedVec, + extensions: Extensions, +} + +impl PortableCredentialBody { + /// Construct one sorted selective export bound to exact account authority and issuer facts. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + subject_keys: Vec, + issuer_account_id: AccountId, + issuer_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Timestamp, + claims: Vec, + extensions: Extensions, + ) -> Result { + Self::from_parts( + account_id, + checkpoint_id, + account_epoch, + subject_keys, + issuer_account_id, + issuer_signing_key, + issued_at, + expires_at, + claims, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_parts( + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + mut subject_keys: Vec, + issuer_account_id: AccountId, + issuer_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Timestamp, + mut claims: Vec, + extensions: Extensions, + ) -> Result { + if subject_keys.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "portable credential subject keys", + }); + } + if claims.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "portable credential selected claims", + }); + } + if issued_at >= expires_at { + return Err(IdentityError::InvalidRelationship { + resource: "portable credential validity interval", + }); + } + extensions.validate_critical(&[])?; + subject_keys.sort_unstable(); + if subject_keys.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(IdentityError::DuplicateElement { + resource: "portable credential subject keys", + }); + } + claims.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + if claims.windows(2).any(|pair| pair[0].name == pair[1].name) { + return Err(IdentityError::DuplicateElement { + resource: "portable credential claim names", + }); + } + let body = Self { + protocol_version: ProtocolVersion::V1, + account_id, + checkpoint_id, + account_epoch, + subject_keys: BoundedVec::new("portable credential subject keys", subject_keys)?, + issuer_account_id, + issuer_signing_key, + issued_at, + expires_at, + claims: BoundedVec::new("portable credential selected claims", claims)?, + extensions, + }; + let encoded_len = encode_wire(&body)?.len(); + if encoded_len > MAX_PORTABLE_CREDENTIAL_BYTES { + return Err(IdentityError::limit( + "portable credential body bytes", + encoded_len, + MAX_PORTABLE_CREDENTIAL_BYTES, + )); + } + Ok(body) + } + + /// Domain-separated canonical bytes which the issuer signs exactly. + pub fn signing_bytes(&self) -> Result, IdentityError> { + domain_message(PORTABLE_CREDENTIAL_SIGNING_DOMAIN, &encode_wire(self)?) + } + + /// Stable subject account. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact subject checkpoint. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Exact subject account epoch. + pub const fn account_epoch(&self) -> Epoch { + self.account_epoch + } + + /// Sorted public subject keys. + pub fn subject_keys(&self) -> &[SigningPublicKey] { + self.subject_keys.as_slice() + } + + /// Account naming the issuer. + pub const fn issuer_account_id(&self) -> AccountId { + self.issuer_account_id + } + + /// Exact issuer signing key. + pub const fn issuer_signing_key(&self) -> SigningPublicKey { + self.issuer_signing_key + } + + /// Explicit issuance time. + pub const fn issued_at(&self) -> Timestamp { + self.issued_at + } + + /// Exclusive credential expiry time. + pub const fn expires_at(&self) -> Timestamp { + self.expires_at + } + + /// Sorted claims deliberately disclosed by this export. + pub fn claims(&self) -> &[CredentialClaim] { + self.claims.as_slice() + } +} + +impl<'de> Deserialize<'de> for PortableCredentialBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + subject_keys: BoundedVec, + issuer_account_id: AccountId, + issuer_signing_key: SigningPublicKey, + issued_at: Timestamp, + expires_at: Timestamp, + claims: BoundedVec, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::from_parts( + wire.account_id, + wire.checkpoint_id, + wire.account_epoch, + wire.subject_keys.into_vec(), + wire.issuer_account_id, + wire.issuer_signing_key, + wire.issued_at, + wire.expires_at, + wire.claims.into_vec(), + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for PortableCredentialBody { + const RESOURCE: &'static str = "portable credential body bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PORTABLE_CREDENTIAL_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Issuer-signed selectively disclosed portable credential. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedPortableCredential { + body: PortableCredentialBody, + issuer_signature: AlgorithmSignature, +} + +impl SignedPortableCredential { + /// Validate and retain one exact issuer signature. + pub fn try_new( + body: PortableCredentialBody, + issuer_signature: AlgorithmSignature, + ) -> Result { + verify_exact_signature( + body.issuer_signing_key, + &issuer_signature, + &body.signing_bytes()?, + )?; + let credential = Self { + body, + issuer_signature, + }; + let encoded_len = encode_wire(&credential)?.len(); + if encoded_len > MAX_PORTABLE_CREDENTIAL_BYTES { + return Err(IdentityError::limit( + "signed portable credential bytes", + encoded_len, + MAX_PORTABLE_CREDENTIAL_BYTES, + )); + } + Ok(credential) + } + + /// Exact signed credential body. + pub const fn body(&self) -> &PortableCredentialBody { + &self.body + } + + /// Typed issuer signature. + pub const fn issuer_signature(&self) -> &AlgorithmSignature { + &self.issuer_signature + } +} + +impl<'de> Deserialize<'de> for SignedPortableCredential { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (body, signature) = + <(PortableCredentialBody, AlgorithmSignature)>::deserialize(deserializer)?; + Self::try_new(body, signature).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for SignedPortableCredential { + const RESOURCE: &'static str = "signed portable credential bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PORTABLE_CREDENTIAL_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Caller-supplied exact authority and time expected for credential verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CredentialVerificationContext { + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + issuer_account_id: AccountId, + issuer_signing_key: SigningPublicKey, + authority_time: Timestamp, +} + +impl CredentialVerificationContext { + /// Construct an explicit credential verification context without ambient time or lookup. + pub const fn try_new( + account_id: AccountId, + checkpoint_id: CheckpointId, + account_epoch: Epoch, + issuer_account_id: AccountId, + issuer_signing_key: SigningPublicKey, + authority_time: Timestamp, + ) -> Result { + Ok(Self { + account_id, + checkpoint_id, + account_epoch, + issuer_account_id, + issuer_signing_key, + authority_time, + }) + } +} + +/// Verified portable-credential fact; it grants no account authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedPortableCredential { + body: PortableCredentialBody, +} + +impl VerifiedPortableCredential { + /// Explicit claims selected for and revealed by this verified export. + pub fn claims(&self) -> &[CredentialClaim] { + self.body.claims() + } + + /// Exact subject keys bound by the issuer signature. + pub fn subject_keys(&self) -> &[SigningPublicKey] { + self.body.subject_keys() + } +} + +/// Verify an exact signed credential against caller-authenticated account, issuer, and time facts. +pub fn verify_portable_credential( + credential: &SignedPortableCredential, + context: &CredentialVerificationContext, +) -> Result { + let body = credential.body(); + if body.account_id != context.account_id + || body.checkpoint_id != context.checkpoint_id + || body.account_epoch != context.account_epoch + || body.issuer_account_id != context.issuer_account_id + || body.issuer_signing_key != context.issuer_signing_key + { + return Err(IdentityError::InvalidRelationship { + resource: "portable credential verification context", + }); + } + if context.authority_time < body.issued_at || context.authority_time >= body.expires_at { + return Err(IdentityError::StaleEvidence); + } + verify_exact_signature( + body.issuer_signing_key, + credential.issuer_signature(), + &body.signing_bytes()?, + )?; + Ok(VerifiedPortableCredential { body: body.clone() }) +} + +/// Secret passphrase used only to unwrap one encrypted account backup. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +pub struct BackupPassphrase(Zeroizing>); + +impl BackupPassphrase { + /// Take ownership of a nonempty, bounded passphrase. + pub fn try_new(bytes: Vec) -> Result { + if bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "backup passphrase", + }); + } + if bytes.len() > PRIVATE_BACKUP_PASSPHRASE_BYTES { + return Err(IdentityError::limit( + "backup passphrase", + bytes.len(), + PRIVATE_BACKUP_PASSPHRASE_BYTES, + )); + } + Ok(Self(Zeroizing::new(bytes))) + } + + fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl fmt::Debug for BackupPassphrase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("BackupPassphrase()") + } +} + +/// Bounded application-private material carried separately from account authority. +/// +/// This type is intentionally neither `Copy` nor `Clone` and never implements a wire codec. +#[derive(PartialEq, Eq)] +pub struct ApplicationBackupData(Zeroizing>); + +impl ApplicationBackupData { + /// Take ownership of nonempty application-private backup bytes. + pub fn try_new(bytes: Vec) -> Result { + if bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "application backup data", + }); + } + if bytes.len() > MAX_APPLICATION_BACKUP_DATA_BYTES { + return Err(IdentityError::limit( + "application backup data", + bytes.len(), + MAX_APPLICATION_BACKUP_DATA_BYTES, + )); + } + Ok(Self(Zeroizing::new(bytes))) + } + + /// Borrow restored application-private bytes while retaining zeroizing ownership. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl fmt::Debug for ApplicationBackupData { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ApplicationBackupData()") + } +} + +/// Bounded public authority material sufficient to reconstruct and verify one account checkpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BackupAuthorityBundle { + protocol_version: ProtocolVersion, + genesis: AccountGenesis, + events: BoundedVec, + checkpoint: SignedCheckpoint, + checkpoint_id: CheckpointId, + extensions: Extensions, +} + +impl BackupAuthorityBundle { + /// Construct and fully validate a bounded genesis-to-checkpoint authority chain. + pub fn try_new( + genesis: AccountGenesis, + events: Vec, + checkpoint: SignedCheckpoint, + ) -> Result { + Self::from_parts(genesis, events, checkpoint, Extensions::default()) + } + + fn from_parts( + genesis: AccountGenesis, + events: Vec, + checkpoint: SignedCheckpoint, + extensions: Extensions, + ) -> Result { + if events.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "backup authority events", + }); + } + extensions.validate_critical(&[])?; + let checkpoint_id = checkpoint.checkpoint_id()?; + let bundle = Self { + protocol_version: ProtocolVersion::V1, + genesis, + events: BoundedVec::new("backup authority events", events)?, + checkpoint, + checkpoint_id, + extensions, + }; + bundle.validate_authority()?; + let encoded_len = encode_wire(&bundle)?.len(); + if encoded_len > MAX_PRIVATE_BACKUP_BYTES { + return Err(IdentityError::limit( + "backup authority bundle bytes", + encoded_len, + MAX_PRIVATE_BACKUP_BYTES, + )); + } + Ok(bundle) + } + + /// Account named by the fully verified backup checkpoint. + pub const fn account_id(&self) -> AccountId { + self.checkpoint.body().account_id() + } + + /// Stable identifier of the fully verified backup checkpoint. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Account epoch named by the fully verified backup checkpoint. + pub const fn account_epoch(&self) -> Epoch { + self.checkpoint.body().account_epoch() + } + + /// Canonical account genesis retained by this authority backup. + pub const fn genesis(&self) -> &AccountGenesis { + &self.genesis + } + + /// Bounded advancing account events retained in semantic order. + pub fn events(&self) -> &[AuthorizedEvent] { + self.events.as_slice() + } + + /// Signed checkpoint retained in the authority bundle. + pub const fn checkpoint(&self) -> &SignedCheckpoint { + &self.checkpoint + } + + fn validate_authority(&self) -> Result { + if self.protocol_version != ProtocolVersion::V1 + || self.checkpoint.checkpoint_id()? != self.checkpoint_id + || self.genesis.account_id()? != self.account_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "backup authority checkpoint", + }); + } + self.extensions.validate_critical(&[])?; + let mut state = AccountState::from_genesis(&self.genesis)?; + for event in self.events.as_slice() { + if state.validate_and_apply(event)?.disposition() != ApplyDisposition::Applied { + return Err(IdentityError::InvalidRelationship { + resource: "backup authority advancing event chain", + }); + } + } + let verified_checkpoint = + verify_checkpoint(&state, &self.checkpoint, None).or_else(|_| { + let transition_event = + self.events + .as_slice() + .last() + .ok_or(IdentityError::EmptyCollection { + resource: "backup authority events", + })?; + verify_checkpoint(&state, &self.checkpoint, Some(transition_event)) + })?; + Ok(RestoredAccountAuthority { + state, + checkpoint: verified_checkpoint, + }) + } +} + +impl<'de> Deserialize<'de> for BackupAuthorityBundle { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + genesis: AccountGenesis, + events: BoundedVec, + checkpoint: SignedCheckpoint, + checkpoint_id: CheckpointId, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + let bundle = Self::from_parts( + wire.genesis, + wire.events.into_vec(), + wire.checkpoint, + wire.extensions, + ) + .map_err(de::Error::custom)?; + if bundle.checkpoint_id != wire.checkpoint_id { + return Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "backup authority checkpoint identifier", + })); + } + Ok(bundle) + } +} + +impl CanonicalCodec for BackupAuthorityBundle { + const RESOURCE: &'static str = "backup authority bundle bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PRIVATE_BACKUP_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Fully replayed account authority recovered from an authenticated backup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestoredAccountAuthority { + state: AccountState, + checkpoint: VerifiedCheckpoint, +} + +impl RestoredAccountAuthority { + /// Fully validated projected account state. + pub const fn state(&self) -> &AccountState { + &self.state + } + + /// Stable identifier of the checkpoint verified against the restored state. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint.checkpoint_id() + } + + /// Fully verified signed checkpoint and any retained transition witness. + pub const fn checkpoint(&self) -> &VerifiedCheckpoint { + &self.checkpoint + } +} + +/// Result for application-private data, deliberately independent of account authority recovery. +pub enum ApplicationDataRestoration { + /// No application-private bytes were present in the authenticated backup. + Unavailable, + /// Application-private bytes were authenticated and restored. + Restored(ApplicationBackupData), +} + +impl fmt::Debug for ApplicationDataRestoration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable => formatter.write_str("Unavailable"), + Self::Restored(_) => formatter.write_str("Restored()"), + } + } +} + +/// Authenticated backup outcome with account authority and application data reported separately. +pub struct BackupRestoration { + account_authority: RestoredAccountAuthority, + application_data: ApplicationDataRestoration, +} + +impl BackupRestoration { + /// Fully validated account authority, independent of application-data availability. + pub const fn account_authority(&self) -> &RestoredAccountAuthority { + &self.account_authority + } + + /// Authenticated application-data outcome. + pub const fn application_data(&self) -> &ApplicationDataRestoration { + &self.application_data + } +} + +impl fmt::Debug for BackupRestoration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BackupRestoration") + .field("account_authority", &self.account_authority) + .field("application_data", &self.application_data) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct BackupKdfParameters { + algorithm_code: u16, + version: u32, + memory_kib: u32, + iterations: u32, + lanes: u32, + output_bytes: u32, +} + +impl BackupKdfParameters { + const FIXED_V1: Self = Self { + algorithm_code: PRIVATE_BACKUP_ARGON2ID_CODE, + version: PRIVATE_BACKUP_ARGON2_VERSION, + memory_kib: PRIVATE_BACKUP_ARGON2_MEMORY_KIB, + iterations: PRIVATE_BACKUP_ARGON2_ITERATIONS, + lanes: PRIVATE_BACKUP_ARGON2_LANES, + output_bytes: PRIVATE_BACKUP_ARGON2_OUTPUT_BYTES, + }; + + fn validate(self) -> Result<(), IdentityError> { + if self != Self::FIXED_V1 { + return Err(IdentityError::InvalidRelationship { + resource: "backup password KDF profile", + }); + } + Ok(()) + } +} + +#[derive(Serialize)] +struct BackupPayloadRef<'a> { + protocol_version: ProtocolVersion, + authority_bundle: &'a BackupAuthorityBundle, + application_data: Option<&'a [u8]>, + extensions: &'a Extensions, +} + +#[derive(Deserialize)] +struct BackupPayload { + protocol_version: ProtocolVersion, + authority_bundle: BackupAuthorityBundle, + application_data: Option>, + extensions: Extensions, +} + +/// Canonical versioned envelope containing encrypted authority and optional application data. +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct BackupEnvelope { + protocol_version: ProtocolVersion, + artifact_kind_code: u16, + password_kdf: BackupKdfParameters, + wrapping_aead: AeadAlgorithm, + content_aead: AeadAlgorithm, + context: PrivateArtifactContext, + salt: [u8; PRIVATE_ARTIFACT_SALT_BYTES], + wrapping_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + wrapped_content_key: BoundedBytes, + ciphertext: BoundedBytes, + extensions: Extensions, +} + +impl BackupEnvelope { + /// Encrypt a validated account backup using fallible operating-system entropy. + #[cfg(feature = "os-rng")] + #[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))] + pub fn seal( + context: PrivateArtifactContext, + passphrase: &BackupPassphrase, + authority_bundle: &BackupAuthorityBundle, + application_data: Option<&ApplicationBackupData>, + ) -> Result { + let mut salt = [0; PRIVATE_ARTIFACT_SALT_BYTES]; + let mut wrapping_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_key = Zeroizing::new([0; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES]); + getrandom::fill(&mut salt).map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(&mut wrapping_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(&mut content_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; + getrandom::fill(content_key.as_mut()).map_err(|_| IdentityError::EntropyUnavailable)?; + Self::seal_with_randomness( + context, + passphrase, + authority_bundle, + application_data, + salt, + wrapping_nonce, + content_nonce, + content_key, + ) + } + + /// Encrypt a validated account backup using injected fallible cryptographic entropy. + pub fn seal_with_rng( + context: PrivateArtifactContext, + passphrase: &BackupPassphrase, + authority_bundle: &BackupAuthorityBundle, + application_data: Option<&ApplicationBackupData>, + rng: &mut impl TryCryptoRng, + ) -> Result { + let mut salt = [0; PRIVATE_ARTIFACT_SALT_BYTES]; + let mut wrapping_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_nonce = [0; PRIVATE_ARTIFACT_NONCE_BYTES]; + let mut content_key = Zeroizing::new([0; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES]); + rng.try_fill_bytes(&mut salt) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(&mut wrapping_nonce) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(&mut content_nonce) + .map_err(|_| IdentityError::EntropyUnavailable)?; + rng.try_fill_bytes(content_key.as_mut()) + .map_err(|_| IdentityError::EntropyUnavailable)?; + Self::seal_with_randomness( + context, + passphrase, + authority_bundle, + application_data, + salt, + wrapping_nonce, + content_nonce, + content_key, + ) + } + + #[allow(clippy::too_many_arguments)] + fn seal_with_randomness( + context: PrivateArtifactContext, + passphrase: &BackupPassphrase, + authority_bundle: &BackupAuthorityBundle, + application_data: Option<&ApplicationBackupData>, + salt: [u8; PRIVATE_ARTIFACT_SALT_BYTES], + wrapping_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_key: Zeroizing<[u8; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES]>, + ) -> Result { + if salt == [0; PRIVATE_ARTIFACT_SALT_BYTES] + || wrapping_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + || content_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + || content_key.as_ref() == [0; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES] + { + return Err(IdentityError::EntropyUnavailable); + } + validate_backup_context(&context, authority_bundle)?; + let payload_extensions = Extensions::default(); + let payload = BackupPayloadRef { + protocol_version: ProtocolVersion::V1, + authority_bundle, + application_data: application_data.map(ApplicationBackupData::as_bytes), + extensions: &payload_extensions, + }; + let plaintext = Zeroizing::new(encode_wire(&payload)?); + if plaintext.len() > MAX_PRIVATE_BACKUP_PLAINTEXT_BYTES { + return Err(IdentityError::limit( + "private backup plaintext bytes", + plaintext.len(), + MAX_PRIVATE_BACKUP_PLAINTEXT_BYTES, + )); + } + let mut envelope = Self { + protocol_version: ProtocolVersion::V1, + artifact_kind_code: PRIVATE_BACKUP_KIND_CODE, + password_kdf: BackupKdfParameters::FIXED_V1, + wrapping_aead: AeadAlgorithm::XChaCha20Poly1305, + content_aead: AeadAlgorithm::XChaCha20Poly1305, + context, + salt, + wrapping_nonce, + content_nonce, + wrapped_content_key: BoundedBytes::new("wrapped backup content key", Vec::new())?, + ciphertext: BoundedBytes::new("private backup ciphertext", Vec::new())?, + extensions: Extensions::default(), + }; + let wrapping_key = derive_backup_wrapping_key(passphrase, &envelope.salt)?; + let wrapping_aad = envelope.wrapping_aad()?; + let wrapping_cipher = XChaCha20Poly1305::new(&Key::from(*wrapping_key)); + let wrapped_content_key = wrapping_cipher + .encrypt( + &XNonce::from(envelope.wrapping_nonce), + Payload { + msg: content_key.as_ref(), + aad: &wrapping_aad, + }, + ) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "backup content-key wrapping", + })?; + envelope.wrapped_content_key = + BoundedBytes::new("wrapped backup content key", wrapped_content_key)?; + + let content_aad = envelope.content_aad()?; + let content_cipher = XChaCha20Poly1305::new(&Key::from(*content_key)); + let ciphertext = content_cipher + .encrypt( + &XNonce::from(envelope.content_nonce), + Payload { + msg: plaintext.as_slice(), + aad: &content_aad, + }, + ) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "private backup encryption", + })?; + envelope.ciphertext = BoundedBytes::new("private backup ciphertext", ciphertext)?; + envelope.validate()?; + let encoded_len = encode_wire(&envelope)?.len(); + if encoded_len > MAX_PRIVATE_BACKUP_BYTES { + return Err(IdentityError::limit( + "private backup envelope bytes", + encoded_len, + MAX_PRIVATE_BACKUP_BYTES, + )); + } + Ok(envelope) + } + + /// Authenticate, decrypt, and revalidate all restored account authority. + pub fn restore( + &self, + passphrase: &BackupPassphrase, + ) -> Result { + self.validate()?; + let wrapping_key = derive_backup_wrapping_key(passphrase, &self.salt)?; + let wrapping_aad = self.wrapping_aad()?; + let wrapping_cipher = XChaCha20Poly1305::new(&Key::from(*wrapping_key)); + let content_key = wrapping_cipher + .decrypt( + &XNonce::from(self.wrapping_nonce), + Payload { + msg: self.wrapped_content_key.as_slice(), + aad: &wrapping_aad, + }, + ) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + let content_key: [u8; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES] = content_key + .try_into() + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + let content_key = Zeroizing::new(content_key); + let content_aad = self.content_aad()?; + let content_cipher = XChaCha20Poly1305::new(&Key::from(*content_key)); + let plaintext = Zeroizing::new( + content_cipher + .decrypt( + &XNonce::from(self.content_nonce), + Payload { + msg: self.ciphertext.as_slice(), + aad: &content_aad, + }, + ) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?, + ); + let (payload, remaining) = postcard::take_from_bytes::(plaintext.as_slice()) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + if !remaining.is_empty() + || payload.protocol_version != ProtocolVersion::V1 + || payload.extensions.validate_critical(&[]).is_err() + || validate_backup_context(&self.context, &payload.authority_bundle).is_err() + { + return Err(IdentityError::PrivateArtifactAuthenticationFailed); + } + let account_authority = payload + .authority_bundle + .validate_authority() + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + let application_data = match payload.application_data { + Some(bytes) => ApplicationDataRestoration::Restored( + ApplicationBackupData::try_new(bytes.into_vec()) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?, + ), + None => ApplicationDataRestoration::Unavailable, + }; + Ok(BackupRestoration { + account_authority, + application_data, + }) + } + + /// Exact public context authenticated by both backup encryption layers. + pub const fn context(&self) -> &PrivateArtifactContext { + &self.context + } + + fn validate(&self) -> Result<(), IdentityError> { + if self.protocol_version != ProtocolVersion::V1 { + return Err(IdentityError::UnsupportedVersion { + version: self.protocol_version.get(), + }); + } + if self.artifact_kind_code != PRIVATE_BACKUP_KIND_CODE { + return Err(IdentityError::UnsupportedCodepoint { + registry: "private artifact kind", + code: self.artifact_kind_code, + }); + } + self.password_kdf.validate()?; + if self.wrapping_aead != AeadAlgorithm::XChaCha20Poly1305 + || self.content_aead != AeadAlgorithm::XChaCha20Poly1305 + { + return Err(IdentityError::InvalidRelationship { + resource: "private backup cryptographic profile", + }); + } + if self.salt == [0; PRIVATE_ARTIFACT_SALT_BYTES] + || self.wrapping_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + || self.content_nonce == [0; PRIVATE_ARTIFACT_NONCE_BYTES] + { + return Err(IdentityError::ZeroValue { + resource: "private backup salt or nonce", + }); + } + if self.wrapped_content_key.len() != PRIVATE_ARTIFACT_WRAPPED_KEY_BYTES + || self.ciphertext.len() <= PRIVATE_ARTIFACT_TAG_BYTES + { + return Err(IdentityError::InvalidEncoding); + } + self.extensions.validate_critical(&[]) + } + + fn header_bytes(&self) -> Result, IdentityError> { + encode_wire(&( + self.protocol_version, + self.artifact_kind_code, + self.password_kdf, + self.wrapping_aead, + self.content_aead, + &self.context, + self.salt, + self.wrapping_nonce, + self.content_nonce, + &self.extensions, + )) + } + + fn wrapping_aad(&self) -> Result, IdentityError> { + domain_message(PRIVATE_ARTIFACT_WRAP_DOMAIN, &self.header_bytes()?) + } + + fn content_aad(&self) -> Result, IdentityError> { + let body = encode_wire(&(self.header_bytes()?, self.wrapped_content_key.as_slice()))?; + domain_message(PRIVATE_ARTIFACT_CONTENT_DOMAIN, &body) + } +} + +impl fmt::Debug for BackupEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BackupEnvelope") + .field("context", &self.context) + .field("ciphertext_bytes", &self.ciphertext.len()) + .finish_non_exhaustive() + } +} + +impl<'de> Deserialize<'de> for BackupEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + artifact_kind_code: u16, + password_kdf: BackupKdfParameters, + wrapping_aead: AeadAlgorithm, + content_aead: AeadAlgorithm, + context: PrivateArtifactContext, + salt: [u8; PRIVATE_ARTIFACT_SALT_BYTES], + wrapping_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + content_nonce: [u8; PRIVATE_ARTIFACT_NONCE_BYTES], + wrapped_content_key: BoundedBytes, + ciphertext: BoundedBytes, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + let envelope = Self { + protocol_version: wire.protocol_version, + artifact_kind_code: wire.artifact_kind_code, + password_kdf: wire.password_kdf, + wrapping_aead: wire.wrapping_aead, + content_aead: wire.content_aead, + context: wire.context, + salt: wire.salt, + wrapping_nonce: wire.wrapping_nonce, + content_nonce: wire.content_nonce, + wrapped_content_key: wire.wrapped_content_key, + ciphertext: wire.ciphertext, + extensions: wire.extensions, + }; + envelope.validate().map_err(de::Error::custom)?; + Ok(envelope) + } +} + +impl CanonicalCodec for BackupEnvelope { + const RESOURCE: &'static str = "private backup envelope bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PRIVATE_BACKUP_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +fn validate_backup_context( + context: &PrivateArtifactContext, + authority_bundle: &BackupAuthorityBundle, +) -> Result<(), IdentityError> { + if context.account_id() != authority_bundle.account_id() + || context.checkpoint_id() != authority_bundle.checkpoint_id() + || context.account_epoch() != authority_bundle.account_epoch() + || context.application_id().is_some() + { + return Err(IdentityError::InvalidRelationship { + resource: "private backup authenticated context", + }); + } + Ok(()) +} + +fn nonzero_secret( + bytes: [u8; 32], + resource: &'static str, +) -> Result, IdentityError> { + if bytes == [0; 32] { + return Err(IdentityError::ZeroValue { resource }); + } + Ok(Zeroizing::new(bytes)) +} + +#[cfg(feature = "os-rng")] +fn os_secret() -> Result, IdentityError> { + let mut bytes = [0; 32]; + getrandom::fill(&mut bytes).map_err(|_| IdentityError::EntropyUnavailable)?; + if bytes == [0; 32] { + return Err(IdentityError::EntropyUnavailable); + } + Ok(Zeroizing::new(bytes)) +} + +fn rng_secret(rng: &mut impl TryCryptoRng) -> Result, IdentityError> { + let mut bytes = [0; 32]; + rng.try_fill_bytes(&mut bytes) + .map_err(|_| IdentityError::EntropyUnavailable)?; + if bytes == [0; 32] { + return Err(IdentityError::EntropyUnavailable); + } + Ok(Zeroizing::new(bytes)) +} + +fn keyed_digest(key: &[u8; 32], domain: &[u8], payload: &[u8]) -> Result { + let message = domain_message(domain, payload)?; + Ok(Digest::new( + HashAlgorithm::Blake3_256, + *blake3::keyed_hash(key, &message).as_bytes(), + )) +} + +fn normalize_dns_context(value: &str, resource: &'static str) -> Result { + if value.is_empty() { + return Err(IdentityError::EmptyCollection { resource }); + } + if !value.is_ascii() || value.len() > MAX_RELYING_PARTY_CONTEXT_BYTES { + return Err(IdentityError::limit( + resource, + value.len(), + MAX_RELYING_PARTY_CONTEXT_BYTES, + )); + } + let normalized = value.to_ascii_lowercase(); + for label in normalized.split('.') { + if label.is_empty() + || label.len() > 63 + || !label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(IdentityError::InvalidEncoding); + } + } + Ok(normalized) +} + +fn normalize_claim_name(value: &str) -> Result { + if value.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "portable credential claim name", + }); + } + if !value.is_ascii() || value.len() > MAX_CREDENTIAL_CLAIM_NAME_BYTES { + return Err(IdentityError::limit( + "portable credential claim name", + value.len(), + MAX_CREDENTIAL_CLAIM_NAME_BYTES, + )); + } + let normalized = value.to_ascii_lowercase(); + if !normalized + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || !normalized + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + || !normalized + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(IdentityError::InvalidEncoding); + } + Ok(normalized) +} + +fn verify_exact_signature( + signing_key: SigningPublicKey, + signature: &AlgorithmSignature, + message: &[u8], +) -> Result<(), IdentityError> { + crate::verifier::verify_algorithm_signature( + signing_key.algorithm().code(), + signing_key.as_bytes(), + signature, + message, + ) +} + +fn derive_backup_wrapping_key( + passphrase: &BackupPassphrase, + salt: &[u8; PRIVATE_ARTIFACT_SALT_BYTES], +) -> Result, IdentityError> { + let parameters = Params::new( + PRIVATE_BACKUP_ARGON2_MEMORY_KIB, + PRIVATE_BACKUP_ARGON2_ITERATIONS, + PRIVATE_BACKUP_ARGON2_LANES, + Some(PRIVATE_ARTIFACT_CONTENT_KEY_BYTES), + ) + .map_err(|_| IdentityError::InvalidRelationship { + resource: "fixed backup password KDF parameters", + })?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, parameters); + let mut wrapping_key = Zeroizing::new([0; PRIVATE_ARTIFACT_CONTENT_KEY_BYTES]); + argon2 + .hash_password_into(passphrase.as_bytes(), salt, wrapping_key.as_mut()) + .map_err(|_| IdentityError::PrivateArtifactAuthenticationFailed)?; + Ok(wrapping_key) +} + +fn derive_metadata_wrapping_key( + key: &PrivateMetadataKey, + salt: &[u8; PRIVATE_ARTIFACT_SALT_BYTES], +) -> Zeroizing<[u8; PRIVATE_METADATA_KEY_BYTES]> { + let mut material = + Zeroizing::new([0_u8; PRIVATE_METADATA_KEY_BYTES + PRIVATE_ARTIFACT_SALT_BYTES]); + material[..PRIVATE_METADATA_KEY_BYTES].copy_from_slice(key.as_bytes()); + material[PRIVATE_METADATA_KEY_BYTES..].copy_from_slice(salt); + Zeroizing::new(blake3::derive_key( + PRIVATE_METADATA_KDF_CONTEXT, + material.as_ref(), + )) +} + +fn domain_message(domain: &[u8], body: &[u8]) -> Result, IdentityError> { + let capacity = domain + .len() + .checked_add(1) + .and_then(|length| length.checked_add(body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "private artifact associated-data bytes", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(domain); + message.push(0); + message.extend_from_slice(body); + Ok(message) +} diff --git a/protocols/krikos-identity/src/proposal.rs b/protocols/krikos-identity/src/proposal.rs new file mode 100644 index 00000000000..5b160a8f71f --- /dev/null +++ b/protocols/krikos-identity/src/proposal.rs @@ -0,0 +1,220 @@ +//! Non-authoritative device-authorization proposal produced by pairing. + +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + AccountId, DeviceDescriptor, DeviceId, Digest, Extensions, HashAlgorithm, IdentityError, + PairingConfirmationContext, PairingProofId, PairingTicketId, PairingTranscriptId, + ProtocolVersion, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::MAX_ACCOUNT_EVENT_BYTES, +}; + +const DEVICE_AUTHORIZATION_PROPOSAL_ID_CONTEXT: &str = + "KRIKOS-ID/device-authorization-proposal-id/v1"; + +/// Domain-separated identifier of a complete non-authoritative pairing proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct DeviceAuthorizationProposalId(Digest); + +impl DeviceAuthorizationProposalId { + /// Borrow the tagged digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } +} + +impl CanonicalCodec for DeviceAuthorizationProposalId { + const RESOURCE: &'static str = "device authorization proposal identifier bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Complete pairing result submitted to later account-event construction. +/// +/// This object is not account authority. Account policy still decides whether and how it becomes +/// an `AuthorizeDevice` event, and controllers sign that exact later event independently. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DeviceAuthorizationProposal { + protocol_version: ProtocolVersion, + account_id: AccountId, + proposed_device: DeviceDescriptor, + proposed_device_id: DeviceId, + ticket_id: PairingTicketId, + transcript_id: PairingTranscriptId, + proof_id: PairingProofId, + confirmation: PairingConfirmationContext, + extensions: Extensions, +} + +impl DeviceAuthorizationProposal { + #[allow(clippy::too_many_arguments)] + pub(crate) fn from_confirmed_pairing( + account_id: AccountId, + proposed_device: DeviceDescriptor, + proposed_device_id: DeviceId, + ticket_id: PairingTicketId, + transcript_id: PairingTranscriptId, + proof_id: PairingProofId, + confirmation: PairingConfirmationContext, + ) -> Result { + if proposed_device.id()? != proposed_device_id { + return Err(IdentityError::InvalidIdentifier { + resource: "paired authorization proposal device", + }); + } + if confirmation.transcript_id() != transcript_id { + return Err(IdentityError::InvalidRelationship { + resource: "paired authorization proposal confirmation", + }); + } + let proposal = Self { + protocol_version: ProtocolVersion::V1, + account_id, + proposed_device, + proposed_device_id, + ticket_id, + transcript_id, + proof_id, + confirmation, + extensions: Extensions::default(), + }; + let encoded_len = encode_wire(&proposal)?.len(); + if encoded_len > MAX_ACCOUNT_EVENT_BYTES { + return Err(IdentityError::limit( + "device authorization proposal bytes", + encoded_len, + MAX_ACCOUNT_EVENT_BYTES, + )); + } + Ok(proposal) + } + + /// Domain-separated proposal identifier. + pub fn proposal_id(&self) -> Result { + Ok(DeviceAuthorizationProposalId(Digest::new( + HashAlgorithm::Blake3_256, + blake3::derive_key( + DEVICE_AUTHORIZATION_PROPOSAL_ID_CONTEXT, + &crate::CanonicalWire::to_canonical_bytes(self)?, + ), + ))) + } + + /// Account this proposal asks to extend. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Complete proposed descriptor. + pub const fn proposed_device(&self) -> &DeviceDescriptor { + &self.proposed_device + } + + /// Exact identifier derived from the descriptor. + pub const fn proposed_device_id(&self) -> DeviceId { + self.proposed_device_id + } + + /// One-time ticket whose ceremony produced this proposal. + pub const fn ticket_id(&self) -> PairingTicketId { + self.ticket_id + } + + /// Complete authenticated pairing transcript. + pub const fn transcript_id(&self) -> PairingTranscriptId { + self.transcript_id + } + + /// Complete four-role possession proof. + pub const fn proof_id(&self) -> PairingProofId { + self.proof_id + } + + /// Exact two-sided short-auth confirmation context. + pub const fn confirmation(&self) -> PairingConfirmationContext { + self.confirmation + } +} + +impl<'de> Deserialize<'de> for DeviceAuthorizationProposal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let ( + protocol_version, + account_id, + proposed_device, + proposed_device_id, + ticket_id, + transcript_id, + proof_id, + confirmation, + extensions, + ) = <( + ProtocolVersion, + AccountId, + DeviceDescriptor, + DeviceId, + PairingTicketId, + PairingTranscriptId, + PairingProofId, + PairingConfirmationContext, + Extensions, + )>::deserialize(deserializer)?; + if protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: protocol_version.get(), + })); + } + if proposed_device.id().map_err(de::Error::custom)? != proposed_device_id { + return Err(de::Error::custom(IdentityError::InvalidIdentifier { + resource: "paired authorization proposal device", + })); + } + if confirmation.transcript_id() != transcript_id { + return Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "paired authorization proposal confirmation", + })); + } + extensions + .validate_critical(&[]) + .map_err(de::Error::custom)?; + if !extensions.as_slice().is_empty() { + return Err(de::Error::custom(IdentityError::InvalidRelationship { + resource: "device authorization proposal extensions", + })); + } + Ok(Self { + protocol_version, + account_id, + proposed_device, + proposed_device_id, + ticket_id, + transcript_id, + proof_id, + confirmation, + extensions, + }) + } +} + +impl CanonicalCodec for DeviceAuthorizationProposal { + const RESOURCE: &'static str = "device authorization proposal bytes"; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} diff --git a/protocols/krikos-identity/src/provider.rs b/protocols/krikos-identity/src/provider.rs new file mode 100644 index 00000000000..2a46706eda3 --- /dev/null +++ b/protocols/krikos-identity/src/provider.rs @@ -0,0 +1,3056 @@ +//! Crash-safe provider-log state machines and bounded proof-serving contracts. + +#[cfg(test)] +use std::cell::Cell; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex, MutexGuard}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + AccountGenesis, AccountId, AccountState, ApplyDisposition, AuthorizedEvent, CheckpointId, + CheckpointTransitionKind, Digest, Epoch, EventId, Extensions, IdentityError, InclusionReceipt, + ProjectionLifecycle, ProviderAuditArtifact, ProviderAuditSnapshot, ProviderCheckpointBundle, + ProviderCheckpointLineagePage, ProviderDescriptor, ProviderHeadBody, ProviderHeadSigner, + ProviderId, ProviderKeyVersion, ProviderLogAdmission, ProviderLogEntryBody, ProviderLogId, + ProviderLogSubject, ProviderPolicy, PublishedCheckpoint, Sequence, SignedCheckpoint, + SignedProviderHead, Timestamp, VerifiedCheckpoint, build_checkpoint_body, + build_provider_checkpoint_bundle_from_genesis, build_provider_checkpoint_bundle_from_prior, + limits::{MAX_HISTORY_PAGE_EVENTS, MAX_MERKLE_LOG_LEAVES, MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES}, + merkle::{AppendOnlyMerkleLog, MerkleConsistencyProof}, + schema::BoundedVec, +}; + +const MAX_PROVIDER_COMPACTION_MANIFESTS: usize = 256; + +#[cfg(test)] +thread_local! { + static PROVIDER_GENERATION_VALIDATION_COUNT: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +fn reset_provider_generation_validation_count() { + PROVIDER_GENERATION_VALIDATION_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +fn provider_generation_validation_count() -> usize { + PROVIDER_GENERATION_VALIDATION_COUNT.with(Cell::get) +} + +fn record_provider_generation_validation() { + #[cfg(test)] + PROVIDER_GENERATION_VALIDATION_COUNT.with(|count| count.set(count.get().saturating_add(1))); +} + +struct ProviderCommitmentFlavor { + hasher: blake3::Hasher, +} + +impl ProviderCommitmentFlavor { + fn new(domain: &[u8]) -> Self { + assert!( + domain.is_ascii(), + "provider commitment domain must contain only ASCII bytes" + ); + assert!( + domain.ends_with(b"/v1"), + "provider commitment domain must name its v1 schema" + ); + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + Self { hasher } + } +} + +impl postcard::ser_flavors::Flavor for ProviderCommitmentFlavor { + type Output = blake3::Hash; + + fn try_push(&mut self, data: u8) -> postcard::Result<()> { + self.hasher.update(&[data]); + Ok(()) + } + + fn try_extend(&mut self, data: &[u8]) -> postcard::Result<()> { + self.hasher.update(data); + Ok(()) + } + + fn finalize(self) -> postcard::Result { + Ok(self.hasher.finalize()) + } +} + +pub(crate) fn provider_commitment( + domain: &[u8], + value: &T, +) -> Result { + let hash = postcard::serialize_with_flavor::( + value, + ProviderCommitmentFlavor::new(domain), + ) + .map_err(|_| IdentityError::InvalidEncoding)?; + Ok(Digest::new( + crate::HashAlgorithm::Blake3_256, + *hash.as_bytes(), + )) +} + +#[cfg(feature = "provider-store")] +mod redb; + +mod anchor; +mod compaction; +pub(crate) mod interchange; + +pub use anchor::{ + OpaqueProviderAnchorCommitment, ProviderAnchor, ProviderAnchorEvidence, ProviderAnchorStatus, +}; +pub use compaction::{ + ProviderCompactionAuthorization, ProviderCompactionManifest, ProviderRetainedRange, + ProviderRetentionClass, ProviderRetentionInventory, ProviderRetentionItem, + derive_provider_retention_inventory, verify_provider_compaction, +}; +pub use interchange::{ + MAX_PROVIDER_EXPORT_CHUNK_BYTES, MAX_PROVIDER_EXPORT_CHUNK_ITEMS, + MAX_PROVIDER_EXPORT_ITEM_BYTES, MAX_PROVIDER_PORTABLE_AUDIT_BYTES, + MAX_PROVIDER_PORTABLE_GENERATION_BYTES, ProviderAuditExportAssembler, ProviderAuditExportChunk, + ProviderAuditExportManifest, ProviderExportComponent, ProviderExportComponentDescriptor, + ProviderGenerationExportAssembler, ProviderGenerationExportChunk, + ProviderGenerationExportManifest, ProviderRecoveryExportManifest, +}; +#[cfg(feature = "provider-store")] +pub use redb::RedbProviderStore; + +/// Bounded request metadata evaluated by provider availability controls. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderAdmissionRequest { + encoded_bytes: usize, +} + +impl ProviderAdmissionRequest { + /// Describe a caller-computed append size, rechecked against the actual admission on use. + pub fn new(encoded_bytes: usize) -> Result { + if encoded_bytes == 0 || encoded_bytes > MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES { + return Err(IdentityError::limit( + "provider append request bytes", + encoded_bytes, + MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES, + )); + } + Ok(Self { encoded_bytes }) + } + + /// Compute the checked encoded size of the exact opaque admission payload. + pub fn for_admission(admission: &ProviderLogAdmission) -> Result { + Self::new(encoded_admission_bytes(admission)?) + } + + /// Canonical byte size charged to the provider's bounded admission policy. + pub const fn encoded_bytes(self) -> usize { + self.encoded_bytes + } + + fn validate_for(self, admission: &ProviderLogAdmission) -> Result<(), IdentityError> { + let required = encoded_admission_bytes(admission)?; + if self.encoded_bytes < required { + return Err(IdentityError::InvalidRelationship { + resource: "provider append request byte undercharge", + }); + } + Ok(()) + } +} + +/// Provider-local availability control, which can deny but never grant protocol authority. +pub trait ProviderAdmissionControl { + /// Apply bounded abuse and capacity controls to an already verified protocol admission. + fn check( + &self, + admission: ProviderLogAdmission, + request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError>; +} + +/// One-shot capability to append an already verified provider-log admission. +#[derive(Debug, PartialEq, Eq)] +pub struct ProviderAppendPermit { + admission: ProviderLogAdmission, + request: ProviderAdmissionRequest, +} + +/// Apply availability controls without allowing them to manufacture provider-log authority. +pub fn authorize_provider_append( + admission: ProviderLogAdmission, + request: ProviderAdmissionRequest, + control: &C, +) -> Result { + request.validate_for(&admission)?; + control.check(admission.clone(), request)?; + Ok(ProviderAppendPermit { admission, request }) +} + +/// Immutable state summary for one explicit provider log and signing-key generation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderGenerationSnapshot { + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + tree_size: u64, + tree_root: Digest, + latest_head: Option, +} + +/// Exact address of one independently persisted provider-log generation. +/// +/// No component is inferred: key rotation creates a new provider ID and every log rollover uses a +/// new log ID, while signing-key version remains explicit inside that exact pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ProviderGenerationRoute { + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, +} + +impl ProviderGenerationRoute { + /// Bind an authenticated provider descriptor to one explicit log/key generation. + pub fn new( + provider: &ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + ) -> Result { + Ok(Self { + provider_id: provider.id()?, + log_id, + key_version, + }) + } + + /// Exact provider identity component. + pub const fn provider_id(self) -> ProviderId { + self.provider_id + } + + /// Exact provider log-generation component. + pub const fn log_id(self) -> ProviderLogId { + self.log_id + } + + /// Exact provider signing-key generation component. + pub const fn key_version(self) -> ProviderKeyVersion { + self.key_version + } +} + +/// Store capability required by the exact multi-generation registry. +pub trait AddressedProviderGeneration { + /// Return this store's immutable generation address. + fn generation_route(&self) -> Result; +} + +/// Exact-address registry for independently active, sealed, or archived generations. +/// +/// The registry intentionally exposes no implicit current/latest winner. Callers must supply the +/// complete route, and account-policy routing additionally rejects provider IDs not named by that +/// exact policy revision. +#[derive(Debug, Clone)] +pub struct ProviderGenerationRegistry { + generations: BTreeMap, +} + +impl Default for ProviderGenerationRegistry { + fn default() -> Self { + Self { + generations: BTreeMap::new(), + } + } +} + +impl ProviderGenerationRegistry { + /// Create an empty exact-address registry. + pub fn new() -> Self { + Self::default() + } + + /// Insert one store under its authenticated immutable address. + pub fn insert(&mut self, store: S) -> Result { + let route = store.generation_route()?; + if self.generations.contains_key(&route) { + return Err(IdentityError::DuplicateElement { + resource: "provider generation route", + }); + } + self.generations.insert(route, store); + Ok(route) + } + + /// Resolve only an exact provider/log/key address. + pub fn get(&self, route: ProviderGenerationRoute) -> Option<&S> { + self.generations.get(&route) + } + + /// Require one exact route, rejecting any missing or cross-generation address. + pub fn require(&self, route: ProviderGenerationRoute) -> Result<&S, IdentityError> { + self.get(route).ok_or(IdentityError::InvalidRelationship { + resource: "provider generation route", + }) + } + + /// Resolve an exact route only when its provider ID is named by the account policy. + pub fn for_policy( + &self, + policy: &ProviderPolicy, + route: ProviderGenerationRoute, + ) -> Result<&S, IdentityError> { + let configured = policy + .providers() + .ok_or(IdentityError::InvalidRelationship { + resource: "provider generation account policy", + })?; + if !configured + .iter() + .any(|provider| provider.id().is_ok_and(|id| id == route.provider_id)) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation account policy", + }); + } + self.require(route) + } + + /// Number of independently addressed generations. + pub fn len(&self) -> usize { + self.generations.len() + } + + /// Whether no generation has been registered. + pub fn is_empty(&self) -> bool { + self.generations.is_empty() + } +} + +impl ProviderGenerationSnapshot { + /// Provider descriptor authenticating the generation. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Explicit provider-log generation identifier. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Explicit provider signing-key generation. + pub const fn key_version(&self) -> ProviderKeyVersion { + self.key_version + } + + /// Number of committed leaves. + pub const fn tree_size(&self) -> u64 { + self.tree_size + } + + /// Merkle root for exactly [`Self::tree_size`]. + pub const fn tree_root(&self) -> Digest { + self.tree_root + } + + /// Latest authenticated head issued for this generation. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } +} + +/// One provider-wide append index and canonical entry returned by durable history queries. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderAccountHistoryRecord { + leaf_index: u64, + entry: ProviderLogEntryBody, +} + +impl ProviderAccountHistoryRecord { + /// Provider-wide zero-based append index. + pub const fn leaf_index(&self) -> u64 { + self.leaf_index + } + + /// Canonical entry committed at this index. + pub const fn entry(&self) -> &ProviderLogEntryBody { + &self.entry + } +} + +/// Bounded account-filtered page retaining a provider-wide continuation cursor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAccountHistoryPage { + records: Vec, + next_cursor: Option, +} + +impl ProviderAccountHistoryPage { + /// Matching entries in provider-wide append order. + pub fn records(&self) -> &[ProviderAccountHistoryRecord] { + &self.records + } + + /// Exclusive provider-wide cursor for the next request, if more data remains. + pub const fn next_cursor(&self) -> Option { + self.next_cursor + } +} + +/// Raw retained checkpoint proof held by a locally sealed generation. +/// +/// Older continuation state may have moved exclusively to the verified recovery archive, so this +/// type deliberately does not expose [`ProviderCheckpointBundle::provider_log_admission`]. A +/// caller must verify the proof from a trusted account state or retrieve the complete archive +/// before treating its checkpoint authorization as authoritative. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderRetainedCheckpointEvidence { + material: RetainedCheckpointMaterial, + receipt: InclusionReceipt, +} + +impl ProviderRetainedCheckpointEvidence { + /// Genesis anchor when this retained link remains independently replayable. + pub fn genesis(&self) -> Option<&AccountGenesis> { + self.material.genesis.as_ref() + } + + /// Prior checkpoint required to verify a compacted continuation link. + pub const fn prior_checkpoint_id(&self) -> Option { + self.material.prior_checkpoint_id + } + + /// Exact bounded advancing event chain retained for this link. + pub fn events(&self) -> &[AuthorizedEvent] { + &self.material.events + } + + /// Structurally validated signed checkpoint whose authority still requires replay. + pub const fn checkpoint(&self) -> &SignedCheckpoint { + &self.material.checkpoint + } + + /// Destructive transition evidence, when carried by the signed checkpoint. + pub const fn transition_event(&self) -> Option<&AuthorizedEvent> { + self.material.transition_event.as_ref() + } + + /// Provider-authenticated inclusion of this checkpoint ID at its original leaf index. + pub const fn receipt(&self) -> &InclusionReceipt { + &self.receipt + } +} + +/// Complete bounded export of one provider-log generation for verified mirroring and recovery. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderGenerationExport { + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + entries: Vec, + leaf_hashes: Vec, + latest_head: Option, + receipts: Vec, + checkpoint_bundles: Vec, + compaction_manifests: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderCheckpointBundleWire { + genesis: Option, + prior_checkpoint_id: Option, + events: BoundedVec, + checkpoint: SignedCheckpoint, + transition_event: Option, +} + +impl ProviderGenerationExport { + /// Provider descriptor authenticating this exported generation. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Explicit log generation carried by the export. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Explicit signing-key generation carried by the export. + pub const fn key_version(&self) -> ProviderKeyVersion { + self.key_version + } + + /// Canonical provider entries in append order. + pub fn entries(&self) -> &[ProviderLogEntryBody] { + &self.entries + } + + /// Domain-separated leaf hashes in append order. + pub fn leaf_hashes(&self) -> &[Digest] { + &self.leaf_hashes + } + + /// Latest authenticated head included in the export. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } + + /// Latest durable inclusion receipt retained for each committed leaf. + pub fn receipts(&self) -> &[InclusionReceipt] { + &self.receipts + } + + /// Complete provider-served checkpoint authorization and lineage material in append order. + pub fn checkpoint_bundles(&self) -> &[ProviderCheckpointBundle] { + &self.checkpoint_bundles + } + + /// Verified compaction manifests durably recorded for this exact generation state. + pub fn compaction_manifests(&self) -> &[ProviderCompactionManifest] { + &self.compaction_manifests + } +} + +impl ProviderCheckpointBundleWire { + fn from_bundle(bundle: &ProviderCheckpointBundle) -> Result { + Ok(Self { + genesis: bundle.genesis().cloned(), + prior_checkpoint_id: bundle.prior_checkpoint_id(), + events: BoundedVec::new( + "provider generation checkpoint lineage events", + bundle.events().to_vec(), + )?, + checkpoint: bundle.verified_checkpoint().checkpoint().clone(), + transition_event: bundle.verified_checkpoint().transition_event().cloned(), + }) + } + + fn validate_interchange_shape(&self) -> Result<(), IdentityError> { + match (&self.genesis, self.prior_checkpoint_id) { + (Some(genesis), None) => build_provider_checkpoint_bundle_from_genesis( + genesis, + self.events.as_slice(), + &self.checkpoint, + self.transition_event.as_ref(), + ) + .map(|_| ()), + (None, Some(_)) => { + let account_id = self.checkpoint.body().account_id(); + if self + .events + .as_slice() + .iter() + .any(|event| event.body().account_id() != account_id) + || self + .transition_event + .as_ref() + .is_some_and(|event| event.body().account_id() != account_id) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation checkpoint continuation account", + }); + } + Ok(()) + } + (Some(_), Some(_)) | (None, None) => Err(IdentityError::InvalidRelationship { + resource: "provider generation checkpoint lineage", + }), + } + } +} + +fn decode_provider_checkpoint_bundle_wires( + wires: &[ProviderCheckpointBundleWire], +) -> Result, IdentityError> { + let mut lineage = BTreeMap::::new(); + let mut bundles = Vec::with_capacity(wires.len()); + for wire in wires { + wire.validate_interchange_shape()?; + let (bundle, base_state) = match (&wire.genesis, wire.prior_checkpoint_id) { + (Some(genesis), None) => ( + build_provider_checkpoint_bundle_from_genesis( + genesis, + wire.events.as_slice(), + &wire.checkpoint, + wire.transition_event.as_ref(), + )?, + AccountState::from_genesis(genesis)?, + ), + (None, Some(prior_checkpoint_id)) => { + let (prior, prior_state) = lineage + .get(&prior_checkpoint_id) + .filter(|(prior, _)| { + prior.checkpoint().body().account_id() + == wire.checkpoint.body().account_id() + }) + .ok_or(IdentityError::InvalidRelationship { + resource: "provider generation checkpoint prior lineage", + })?; + ( + build_provider_checkpoint_bundle_from_prior( + prior_state, + prior, + wire.events.as_slice(), + &wire.checkpoint, + wire.transition_event.as_ref(), + )?, + prior_state.clone(), + ) + } + (Some(_), Some(_)) | (None, None) => { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation checkpoint lineage", + }); + } + }; + let (projected, _) = project_bundle_state(base_state, &bundle)?; + let verified = bundle.verified_checkpoint().clone(); + if lineage + .insert(verified.checkpoint_id(), (verified, projected)) + .is_some() + { + return Err(IdentityError::DuplicateElement { + resource: "provider generation checkpoint lineage", + }); + } + bundles.push(bundle); + } + Ok(bundles) +} + +/// Validated full recovery archive binding one exact generation to its complete audit journal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderRecoveryExport { + generation: ProviderGenerationExport, + audit: ProviderAuditSnapshot, + artifacts: Vec, + generation_commitment: Digest, + audit_commitment: Digest, + artifact_commitment: Digest, + recovery_commitment: Digest, +} + +impl ProviderRecoveryExport { + /// Validate and bind a complete generation to the audit snapshot for the exact same head. + pub fn new( + generation: ProviderGenerationExport, + audit: ProviderAuditSnapshot, + ) -> Result { + Self::build_validated(generation, audit).map(|(recovery, _)| recovery) + } + + fn build_validated( + generation: ProviderGenerationExport, + audit: ProviderAuditSnapshot, + ) -> Result<(Self, ProviderGenerationSnapshot), IdentityError> { + let restored = MemoryProviderStore::restore_generation(generation.clone())?; + let (_, snapshot) = restored.export_and_snapshot_from_validated_state()?; + audit.validate()?; + if audit.provider() != generation.provider() + || audit.log_id() != generation.log_id() + || audit.latest_head() != snapshot.latest_head() + { + return Err(IdentityError::InvalidRelationship { + resource: "provider recovery generation audit binding", + }); + } + let artifacts = audit.artifacts_validated()?; + for artifact in &artifacts { + artifact.verify(generation.provider(), generation.log_id())?; + } + let generation_commitment = compaction::provider_generation_export_commitment(&generation)?; + let audit_commitment = audit.commitment_validated()?; + let artifact_commitment = provider_audit_artifact_commitment(&artifacts)?; + let recovery_commitment = provider_recovery_commitment( + generation_commitment, + audit_commitment, + artifact_commitment, + )?; + Ok(( + Self { + generation, + audit, + artifacts, + generation_commitment, + audit_commitment, + artifact_commitment, + recovery_commitment, + }, + snapshot, + )) + } + + /// Complete authenticated provider generation payload. + pub const fn generation(&self) -> &ProviderGenerationExport { + &self.generation + } + + /// Complete validated audit history, including accepted and rejected observations. + pub const fn audit(&self) -> &ProviderAuditSnapshot { + &self.audit + } + + /// Sorted first-class rollback/equivocation artifacts derived from the audit history. + pub fn artifacts(&self) -> &[ProviderAuditArtifact] { + &self.artifacts + } + + /// Exact full generation commitment. + pub const fn generation_commitment(&self) -> Digest { + self.generation_commitment + } + + /// Exact full audit-journal commitment. + pub const fn audit_commitment(&self) -> Digest { + self.audit_commitment + } + + /// Exact sorted non-leaf audit-artifact commitment. + pub const fn artifact_commitment(&self) -> Digest { + self.artifact_commitment + } + + /// Composite recovery commitment binding generation, audit journal, and artifacts. + pub const fn recovery_commitment(&self) -> Digest { + self.recovery_commitment + } + + fn validate(&self) -> Result<(), IdentityError> { + self.validate_with_generation_snapshot().map(|_| ()) + } + + fn validate_with_generation_snapshot( + &self, + ) -> Result { + let (rebuilt, snapshot) = + Self::build_validated(self.generation.clone(), self.audit.clone())?; + if &rebuilt != self { + return Err(IdentityError::InvalidProof); + } + Ok(snapshot) + } +} + +const PROVIDER_AUDIT_ARTIFACT_SET_COMMITMENT_DOMAIN: &[u8] = + b"KRIKOS-ID/provider-audit-artifacts/v1"; +const PROVIDER_RECOVERY_EXPORT_COMMITMENT_DOMAIN: &[u8] = b"KRIKOS-ID/provider-recovery-export/v1"; +const PROVIDER_RETAINED_EVIDENCE_COMMITMENT_DOMAIN: &[u8] = + b"KRIKOS-ID/provider-retained-evidence/v1"; + +#[derive(Serialize)] +struct ProviderAuditArtifactSetCommitmentWire<'a> { + format_version: u16, + artifact_commitments: &'a [Digest], +} + +#[derive(Serialize)] +struct ProviderRecoveryExportCommitmentWire { + format_version: u16, + generation_commitment: Digest, + audit_commitment: Digest, + artifact_commitment: Digest, +} + +fn provider_audit_artifact_commitment( + artifacts: &[ProviderAuditArtifact], +) -> Result { + let artifact_commitments = artifacts + .iter() + .map(ProviderAuditArtifact::commitment) + .collect::, _>>()?; + provider_commitment( + PROVIDER_AUDIT_ARTIFACT_SET_COMMITMENT_DOMAIN, + &ProviderAuditArtifactSetCommitmentWire { + format_version: 1, + artifact_commitments: &artifact_commitments, + }, + ) +} + +fn provider_recovery_commitment( + generation: Digest, + audit: Digest, + artifacts: Digest, +) -> Result { + provider_commitment( + PROVIDER_RECOVERY_EXPORT_COMMITMENT_DOMAIN, + &ProviderRecoveryExportCommitmentWire { + format_version: 1, + generation_commitment: generation, + audit_commitment: audit, + artifact_commitment: artifacts, + }, + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProviderCheckpointIndex { + pub(crate) account_id: AccountId, + pub(crate) greatest_sequence: Sequence, + pub(crate) greatest_epoch: Epoch, + pub(crate) current_checkpoint_id: Option, + pub(crate) projection_heads: Vec, + pub(crate) forked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderGenerationState { + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + leaf_hashes: Vec, + latest_head: Option, + compaction_manifests: Vec, + payload: ProviderGenerationPayload, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ProviderGenerationPayload { + Active(ActiveProviderPayload), + Sealed(Box), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ActiveProviderPayload { + entries: Vec, + receipts: Vec, + checkpoint_bundles: Vec, + checkpoint_index: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RetainedProviderRecord { + leaf_index: u64, + entry: ProviderLogEntryBody, + receipt: InclusionReceipt, +} + +#[derive(Serialize)] +struct RetainedProviderRecordCommitmentWire<'a> { + leaf_index: u64, + entry: &'a ProviderLogEntryBody, + receipt: &'a InclusionReceipt, +} + +struct DerivedRetainedProviderMaterial { + records: Vec, + checkpoint_evidence: Vec, + checkpoint_index: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RetainedCheckpointMaterial { + pub(crate) genesis: Option, + pub(crate) prior_checkpoint_id: Option, + pub(crate) events: Vec, + pub(crate) checkpoint: SignedCheckpoint, + pub(crate) transition_event: Option, +} + +#[derive(Serialize)] +struct RetainedCheckpointMaterialCommitmentWire<'a> { + genesis: Option<&'a AccountGenesis>, + prior_checkpoint_id: Option, + events: &'a [AuthorizedEvent], + checkpoint: &'a SignedCheckpoint, + transition_event: Option<&'a AuthorizedEvent>, +} + +#[derive(Serialize)] +struct ProviderCheckpointIndexCommitmentWire<'a> { + account_id: AccountId, + greatest_sequence: Sequence, + greatest_epoch: Epoch, + current_checkpoint_id: Option, + projection_heads: &'a [EventId], + forked: bool, +} + +#[derive(Serialize)] +struct RetainedProviderEvidenceCommitmentWire<'a> { + format_version: u16, + records: Vec>, + checkpoint_evidence: Vec>, + checkpoint_index: Vec>, + audit_artifact_commitment: Digest, +} + +impl RetainedCheckpointMaterial { + fn from_bundle(bundle: &ProviderCheckpointBundle) -> Self { + let verified = bundle.verified_checkpoint(); + Self { + genesis: bundle.genesis().cloned(), + prior_checkpoint_id: bundle.prior_checkpoint_id(), + events: bundle.events().to_vec(), + checkpoint: verified.checkpoint().clone(), + transition_event: verified.transition_event().cloned(), + } + } + + fn checkpoint_id(&self) -> Result { + self.checkpoint.checkpoint_id() + } + + fn validate_structure(&self) -> Result<(), IdentityError> { + match (&self.genesis, self.prior_checkpoint_id) { + (Some(genesis), None) => { + build_provider_checkpoint_bundle_from_genesis( + genesis, + &self.events, + &self.checkpoint, + self.transition_event.as_ref(), + )?; + return Ok(()); + } + (None, Some(_)) => {} + (Some(_), Some(_)) | (None, None) => { + return Err(IdentityError::InvalidRelationship { + resource: "retained checkpoint continuation anchor", + }); + } + } + if self.events.len() > MAX_HISTORY_PAGE_EVENTS + || self + .events + .iter() + .any(|event| event.body().account_id() != self.checkpoint.body().account_id()) + { + return Err(IdentityError::InvalidRelationship { + resource: "retained checkpoint continuation events", + }); + } + let encoded_events = crate::codec::encode_wire(&self.events)?; + if encoded_events.len() > crate::limits::MAX_HISTORY_PAGE_BYTES { + return Err(IdentityError::limit( + "retained checkpoint continuation bytes", + encoded_events.len(), + crate::limits::MAX_HISTORY_PAGE_BYTES, + )); + } + if let Some(last) = self.events.last() + && (last.event_id()? != self.checkpoint.body().event_head() + || last.body().sequence() != self.checkpoint.body().sequence() + || last.body().resulting_epoch() != self.checkpoint.body().account_epoch()) + { + return Err(IdentityError::InvalidProof); + } + match self.checkpoint.authorization().controller_approvals() { + Some(approvals) => { + if approvals.as_slice().is_empty() || self.transition_event.is_some() { + return Err(IdentityError::InvalidProof); + } + } + None => { + let witness = self + .checkpoint + .authorization() + .transition_witness() + .ok_or(IdentityError::InvalidProof)?; + let event = self + .transition_event + .as_ref() + .ok_or(IdentityError::InvalidProof)?; + let operation_matches = matches!( + (witness.transition_kind(), event.body().operation()), + ( + CheckpointTransitionKind::FinalizeRecovery, + crate::AccountOperation::FinalizeRecovery(_) + ) | ( + CheckpointTransitionKind::RetireAccount, + crate::AccountOperation::RetireAccount(_) + ) + ); + if !operation_matches + || witness.event_id() != event.event_id()? + || witness.event_authorization_id() != event.event_authorization_id()? + || witness.event_id() != self.checkpoint.body().event_head() + { + return Err(IdentityError::InvalidProof); + } + } + } + self.checkpoint_id()?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SealedProviderPayload { + retained_records: Vec, + checkpoint_bundles: Vec, + retained_checkpoint_evidence: Vec, + checkpoint_index: Vec, + manifest: Option, + inventory: Option, + audit_snapshot: Option, + audit_artifacts: Vec, + archive_complete: bool, +} + +impl ProviderGenerationState { + fn active(&self) -> Result<&ActiveProviderPayload, IdentityError> { + match &self.payload { + ProviderGenerationPayload::Active(payload) => Ok(payload), + ProviderGenerationPayload::Sealed(_) => Err(IdentityError::ProviderArchiveRequired), + } + } + + fn active_mut(&mut self) -> Result<&mut ActiveProviderPayload, IdentityError> { + match &mut self.payload { + ProviderGenerationPayload::Active(payload) => Ok(payload), + ProviderGenerationPayload::Sealed(_) => Err(IdentityError::ProviderArchiveRequired), + } + } + + fn tree(&self) -> Result { + AppendOnlyMerkleLog::from_leaf_hashes(self.leaf_hashes.clone()) + } + + fn snapshot(&self) -> Result { + let tree = self.tree()?; + Ok(ProviderGenerationSnapshot { + provider: self.provider.clone(), + log_id: self.log_id, + key_version: self.key_version, + tree_size: tree.tree_size()?, + tree_root: tree.root()?, + latest_head: self.latest_head.clone(), + }) + } + + fn export(&self) -> Result { + let (entries, receipts, checkpoint_bundles) = match &self.payload { + ProviderGenerationPayload::Active(payload) => ( + payload.entries.clone(), + payload.receipts.clone(), + payload.checkpoint_bundles.clone(), + ), + ProviderGenerationPayload::Sealed(payload) if payload.archive_complete => ( + payload + .retained_records + .iter() + .map(|record| record.entry.clone()) + .collect(), + payload + .retained_records + .iter() + .map(|record| record.receipt.clone()) + .collect(), + payload.checkpoint_bundles.clone(), + ), + ProviderGenerationPayload::Sealed(_) => { + return Err(IdentityError::ProviderArchiveRequired); + } + }; + Ok(ProviderGenerationExport { + provider: self.provider.clone(), + log_id: self.log_id, + key_version: self.key_version, + entries, + leaf_hashes: self.leaf_hashes.clone(), + latest_head: self.latest_head.clone(), + receipts, + checkpoint_bundles, + compaction_manifests: self.compaction_manifests.clone(), + }) + } + + fn validate(&self) -> Result<(), IdentityError> { + self.validate_inner(true) + } + + fn validate_cached(&self) -> Result<(), IdentityError> { + self.validate_inner(false) + } + + fn validate_inner(&self, validate_portable_bytes: bool) -> Result<(), IdentityError> { + record_provider_generation_validation(); + if self.key_version != ProviderKeyVersion::GENESIS + || self.leaf_hashes.len() > MAX_MERKLE_LOG_LEAVES + || self.compaction_manifests.len() > MAX_PROVIDER_COMPACTION_MANIFESTS + { + return Err(IdentityError::StorageCorruption); + } + let provider_id = self.provider.id()?; + match &self.payload { + ProviderGenerationPayload::Active(payload) => { + if payload.entries.len() != self.leaf_hashes.len() + || payload.entries.len() != payload.receipts.len() + { + return Err(IdentityError::StorageCorruption); + } + let rebuilt_checkpoint_index = + rebuild_checkpoint_index(&payload.entries, &payload.checkpoint_bundles)?; + if payload.checkpoint_index != rebuilt_checkpoint_index { + return Err(IdentityError::StorageCorruption); + } + for (index, receipt) in payload.receipts.iter().enumerate() { + let leaf_index = + u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider receipt validation index", + })?; + validate_retained_record( + &self.provider, + self.log_id, + &self.leaf_hashes, + &RetainedProviderRecord { + leaf_index, + entry: payload.entries[index].clone(), + receipt: receipt.clone(), + }, + )?; + } + } + ProviderGenerationPayload::Sealed(sealed) => { + for artifact in &sealed.audit_artifacts { + artifact + .verify(&self.provider, self.log_id) + .map_err(|_| IdentityError::StorageCorruption)?; + } + let mut previous_index = None; + for record in &sealed.retained_records { + if previous_index.is_some_and(|previous| previous >= record.leaf_index) { + return Err(IdentityError::StorageCorruption); + } + previous_index = Some(record.leaf_index); + validate_retained_record( + &self.provider, + self.log_id, + &self.leaf_hashes, + record, + )?; + if let ProviderLogSubject::Checkpoint(checkpoint_id) = record.entry.subject() { + let matches = if sealed.archive_complete { + sealed + .checkpoint_bundles + .iter() + .filter(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() + == record.entry.account_id() + && checkpoint.checkpoint_id() == checkpoint_id + }) + .count() + } else { + sealed + .retained_checkpoint_evidence + .iter() + .filter(|evidence| { + evidence.checkpoint.body().account_id() + == record.entry.account_id() + && evidence.checkpoint_id() == Ok(checkpoint_id) + }) + .count() + }; + if matches != 1 { + return Err(IdentityError::StorageCorruption); + } + } + } + let retained_checkpoint_count = sealed + .retained_records + .iter() + .filter(|record| { + matches!(record.entry.subject(), ProviderLogSubject::Checkpoint(_)) + }) + .count(); + let stored_checkpoint_count = if sealed.archive_complete { + sealed.checkpoint_bundles.len() + } else { + sealed.retained_checkpoint_evidence.len() + }; + if retained_checkpoint_count != stored_checkpoint_count { + return Err(IdentityError::StorageCorruption); + } + let mut previous_account = None; + for index in &sealed.checkpoint_index { + if previous_account.is_some_and(|account| account >= index.account_id) + || !sealed + .retained_records + .iter() + .any(|record| record.entry.account_id() == index.account_id) + || (!index.forked + && !sealed.retained_records.iter().any(|record| { + record.entry.account_id() == index.account_id + && index.current_checkpoint_id.is_some_and(|checkpoint_id| { + record.entry.subject() + == ProviderLogSubject::Checkpoint(checkpoint_id) + }) + })) + { + return Err(IdentityError::StorageCorruption); + } + previous_account = Some(index.account_id); + } + let expected_indices = if sealed.archive_complete { + (0..self.leaf_hashes.len()) + .map(|index| { + u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider sealed archive index", + }) + }) + .collect::, IdentityError>>()? + } else { + sealed + .manifest + .as_ref() + .ok_or(IdentityError::StorageCorruption)? + .retained_leaf_indices()? + }; + if sealed + .retained_records + .iter() + .map(|record| record.leaf_index) + .ne(expected_indices) + { + return Err(IdentityError::StorageCorruption); + } + if sealed.archive_complete { + if sealed.manifest.is_some() + || sealed.inventory.is_some() + || !sealed.retained_checkpoint_evidence.is_empty() + { + return Err(IdentityError::StorageCorruption); + } + let audit = sealed + .audit_snapshot + .as_ref() + .ok_or(IdentityError::StorageCorruption)?; + audit + .validate() + .map_err(|_| IdentityError::StorageCorruption)?; + if audit.provider() != &self.provider + || audit.log_id() != self.log_id + || audit.latest_head() != self.latest_head.as_ref() + || audit + .artifacts_validated() + .map_err(|_| IdentityError::StorageCorruption)? + != sealed.audit_artifacts + { + return Err(IdentityError::StorageCorruption); + } + let entries = sealed + .retained_records + .iter() + .map(|record| record.entry.clone()) + .collect::>(); + let rebuilt = rebuild_checkpoint_index(&entries, &sealed.checkpoint_bundles)?; + if rebuilt != sealed.checkpoint_index { + return Err(IdentityError::StorageCorruption); + } + } else { + if !sealed.checkpoint_bundles.is_empty() || sealed.audit_snapshot.is_some() { + return Err(IdentityError::StorageCorruption); + } + for evidence in &sealed.retained_checkpoint_evidence { + evidence + .validate_structure() + .map_err(|_| IdentityError::StorageCorruption)?; + } + let manifest = sealed + .manifest + .as_ref() + .ok_or(IdentityError::StorageCorruption)?; + let inventory = sealed + .inventory + .as_ref() + .ok_or(IdentityError::StorageCorruption)?; + if !self.compaction_manifests.contains(manifest) + || inventory.audit_artifacts() != sealed.audit_artifacts + { + return Err(IdentityError::StorageCorruption); + } + let retained_commitment = retained_provider_payload_commitment( + &sealed.retained_records, + &sealed.retained_checkpoint_evidence, + &sealed.checkpoint_index, + &sealed.audit_artifacts, + )?; + manifest.validate_sealed_evidence(inventory, retained_commitment)?; + } + } + } + for (index, stored_hash) in self.leaf_hashes.iter().enumerate() { + if let ProviderGenerationPayload::Active(payload) = &self.payload + && payload.entries[index].merkle_leaf_hash()? != *stored_hash + { + return Err(IdentityError::StorageCorruption); + } + } + let tree = self.tree()?; + for manifest in &self.compaction_manifests { + manifest.validate_generation( + provider_id, + self.log_id, + self.key_version, + &self.leaf_hashes, + )?; + } + if validate_portable_bytes + && (matches!(&self.payload, ProviderGenerationPayload::Active(_)) + || matches!( + &self.payload, + ProviderGenerationPayload::Sealed(sealed) if sealed.archive_complete + )) + { + interchange::validate_generation_interchange_bounds(&self.export()?)?; + } + match (&self.latest_head, self.leaf_hashes.is_empty()) { + (None, true) => Ok(()), + (None, false) | (Some(_), true) => Err(IdentityError::StorageCorruption), + (Some(head), false) => { + head.verify(&self.provider) + .map_err(|_| IdentityError::StorageCorruption)?; + if head.body().log_id() != self.log_id + || head.body().key_version() != self.key_version + || head.body().tree_size() != tree.tree_size()? + || head.body().tree_root() != tree.root()? + { + return Err(IdentityError::StorageCorruption); + } + Ok(()) + } + } + } +} + +fn validate_retained_record( + provider: &ProviderDescriptor, + log_id: ProviderLogId, + leaf_hashes: &[Digest], + record: &RetainedProviderRecord, +) -> Result<(), IdentityError> { + let index = usize::try_from(record.leaf_index).map_err(|_| IdentityError::StorageCorruption)?; + let stored_hash = leaf_hashes + .get(index) + .copied() + .ok_or(IdentityError::StorageCorruption)?; + if record.entry.provider_id() != provider.id()? + || record.entry.log_id() != log_id + || record.entry.merkle_leaf_hash()? != stored_hash + || record.receipt.leaf_index() != record.leaf_index + || record.receipt.entry() != &record.entry + { + return Err(IdentityError::StorageCorruption); + } + record + .receipt + .verify(provider) + .map_err(|_| IdentityError::StorageCorruption) +} + +fn derive_retained_provider_material( + generation: &ProviderGenerationExport, + inventory: &ProviderRetentionInventory, +) -> Result { + if inventory.tree_size() + != u64::try_from(generation.entries.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider retained generation tree size", + } + })? + { + return Err(IdentityError::InvalidRelationship { + resource: "provider retained inventory generation", + }); + } + let mut indices = inventory + .items() + .iter() + .map(|item| item.leaf_index()) + .collect::>(); + indices.sort_unstable(); + indices.dedup(); + let mut retained_records = Vec::with_capacity(indices.len()); + let mut retained_checkpoint_evidence = Vec::new(); + let mut retained_accounts = std::collections::BTreeSet::new(); + for leaf_index in indices { + let index = usize::try_from(leaf_index).map_err(|_| IdentityError::StorageCorruption)?; + let entry = generation + .entries + .get(index) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + let receipt = generation + .receipts + .get(index) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + if let ProviderLogSubject::Checkpoint(checkpoint_id) = entry.subject() { + let bundle = generation + .checkpoint_bundles + .iter() + .find(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == entry.account_id() + && checkpoint.checkpoint_id() == checkpoint_id + }) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + retained_checkpoint_evidence.push(RetainedCheckpointMaterial::from_bundle(&bundle)); + retained_accounts.insert(entry.account_id()); + } + retained_records.push(RetainedProviderRecord { + leaf_index, + entry, + receipt, + }); + } + let checkpoint_index = + rebuild_checkpoint_index(&generation.entries, &generation.checkpoint_bundles)? + .into_iter() + .filter(|index| retained_accounts.contains(&index.account_id)) + .collect::>(); + Ok(DerivedRetainedProviderMaterial { + records: retained_records, + checkpoint_evidence: retained_checkpoint_evidence, + checkpoint_index, + }) +} + +pub(super) fn retained_provider_evidence_commitment( + generation: &ProviderGenerationExport, + inventory: &ProviderRetentionInventory, +) -> Result { + let retained = derive_retained_provider_material(generation, inventory)?; + retained_provider_payload_commitment( + &retained.records, + &retained.checkpoint_evidence, + &retained.checkpoint_index, + inventory.audit_artifacts(), + ) +} + +fn retained_provider_payload_commitment( + records: &[RetainedProviderRecord], + evidence: &[RetainedCheckpointMaterial], + checkpoint_index: &[ProviderCheckpointIndex], + artifacts: &[ProviderAuditArtifact], +) -> Result { + provider_commitment( + PROVIDER_RETAINED_EVIDENCE_COMMITMENT_DOMAIN, + &RetainedProviderEvidenceCommitmentWire { + format_version: 1, + records: records + .iter() + .map(|record| RetainedProviderRecordCommitmentWire { + leaf_index: record.leaf_index, + entry: &record.entry, + receipt: &record.receipt, + }) + .collect(), + checkpoint_evidence: evidence + .iter() + .map(|retained| RetainedCheckpointMaterialCommitmentWire { + genesis: retained.genesis.as_ref(), + prior_checkpoint_id: retained.prior_checkpoint_id, + events: &retained.events, + checkpoint: &retained.checkpoint, + transition_event: retained.transition_event.as_ref(), + }) + .collect(), + checkpoint_index: checkpoint_index + .iter() + .map(|index| ProviderCheckpointIndexCommitmentWire { + account_id: index.account_id, + greatest_sequence: index.greatest_sequence, + greatest_epoch: index.greatest_epoch, + current_checkpoint_id: index.current_checkpoint_id, + projection_heads: &index.projection_heads, + forked: index.forked, + }) + .collect(), + audit_artifact_commitment: provider_audit_artifact_commitment(artifacts)?, + }, + ) +} + +/// Thread-safe in-memory implementation of the durable provider transaction contract. +#[derive(Debug, Clone)] +pub struct MemoryProviderStore { + state: Arc>, + portable_accounting: Arc>, +} + +impl MemoryProviderStore { + /// Construct an empty store for one explicit provider/log/key generation. + pub fn new( + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + ) -> Result { + if key_version != ProviderKeyVersion::GENESIS { + return Err(IdentityError::InvalidRelationship { + resource: "provider signing-key generation", + }); + } + Ok(Self { + state: Arc::new(Mutex::new(ProviderGenerationState { + provider, + log_id, + key_version, + leaf_hashes: Vec::new(), + latest_head: None, + compaction_manifests: Vec::new(), + payload: ProviderGenerationPayload::Active(ActiveProviderPayload { + entries: Vec::new(), + receipts: Vec::new(), + checkpoint_bundles: Vec::new(), + checkpoint_index: Vec::new(), + }), + })), + portable_accounting: Arc::new(Mutex::new( + interchange::ProviderGenerationPortableAccounting::empty(), + )), + }) + } + + /// Return this store's exact immutable provider/log/key address. + pub fn generation_route(&self) -> Result { + let state = self.lock_state()?; + ProviderGenerationRoute::new(&state.provider, state.log_id, state.key_version) + } + + /// Return an authenticated summary of the currently committed generation. + pub fn snapshot(&self) -> Result { + self.lock_state()?.snapshot() + } + + pub(super) fn export_and_snapshot_from_validated_state( + &self, + ) -> Result<(ProviderGenerationExport, ProviderGenerationSnapshot), IdentityError> { + let state = self.lock_state()?; + Ok((state.export()?, state.snapshot()?)) + } + + /// Serve the unique current checkpoint bundle, failing closed after any retained fork. + pub fn latest_checkpoint_bundle( + &self, + account_id: AccountId, + ) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + Ok(latest_checkpoint_bundle(&state, account_id)?.cloned()) + } + + /// Fetch one exact retained checkpoint branch with its authenticated provider inclusion. + pub fn checkpoint_bundle( + &self, + account_id: AccountId, + checkpoint_id: CheckpointId, + ) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + published_checkpoint_for(&state, account_id, checkpoint_id) + } + + /// Fetch one bounded target-to-genesis lineage page from an explicit retained branch. + pub fn checkpoint_lineage_page( + &self, + account_id: AccountId, + start_checkpoint_id: CheckpointId, + maximum_records: usize, + maximum_bytes: usize, + ) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + checkpoint_lineage_page_for( + &state, + account_id, + start_checkpoint_id, + maximum_records, + maximum_bytes, + ) + } + + /// Fetch raw locally retained checkpoint evidence without minting an append capability. + pub fn retained_checkpoint_evidence( + &self, + account_id: AccountId, + checkpoint_id: CheckpointId, + ) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + retained_checkpoint_evidence_for(&state, account_id, checkpoint_id) + } + + /// Fetch the unique current raw checkpoint evidence from a locally sealed generation. + pub fn latest_retained_checkpoint_evidence( + &self, + account_id: AccountId, + ) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + let ProviderGenerationPayload::Sealed(sealed) = &state.payload else { + return Ok(None); + }; + if sealed.archive_complete { + return Ok(None); + } + let Some(index) = sealed + .checkpoint_index + .iter() + .find(|index| index.account_id == account_id) + else { + return Ok(None); + }; + if index.forked { + return Err(IdentityError::AccountForked); + } + let checkpoint_id = index + .current_checkpoint_id + .ok_or(IdentityError::StorageCorruption)?; + retained_checkpoint_evidence_for(&state, account_id, checkpoint_id) + } + + /// Return all non-leaf rollback/equivocation artifacts retained by a sealed generation. + pub fn retained_audit_artifacts(&self) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + match &state.payload { + ProviderGenerationPayload::Active(_) => Ok(Vec::new()), + ProviderGenerationPayload::Sealed(sealed) => Ok(sealed.audit_artifacts.clone()), + } + } + + /// Return every retained checkpoint bundle in provider append order. + pub fn checkpoint_bundles(&self) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + match &state.payload { + ProviderGenerationPayload::Active(active) => Ok(active.checkpoint_bundles.clone()), + ProviderGenerationPayload::Sealed(sealed) if sealed.archive_complete => { + Ok(sealed.checkpoint_bundles.clone()) + } + ProviderGenerationPayload::Sealed(_) => Err(IdentityError::ProviderArchiveRequired), + } + } + + /// Return every verified compaction manifest durably recorded for this generation. + pub fn compaction_manifests(&self) -> Result, IdentityError> { + let state = self.lock_state()?; + state.validate_cached()?; + Ok(state.compaction_manifests.clone()) + } + + /// Reverify and durably retain a compaction manifest before any external release workflow. + pub fn record_compaction_manifest( + &self, + authorization: &ProviderCompactionAuthorization, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, + ) -> Result { + let mut state = self.lock_state()?; + let mut portable_accounting = self.lock_portable_accounting()?; + state.validate_cached()?; + if matches!(state.payload, ProviderGenerationPayload::Sealed(_)) { + return Err(IdentityError::ProviderArchiveRequired); + } + let manifest = authorization.manifest().clone(); + if state.compaction_manifests.contains(&manifest) { + return Ok(manifest); + } + let mut source = state.export()?; + source + .compaction_manifests + .retain(|candidate| candidate != &manifest); + if &source != mirror.generation() { + return Err(IdentityError::InvalidProof); + } + authorization.manifest().verify(mirror, mirror, inventory)?; + if state.compaction_manifests.len() == MAX_PROVIDER_COMPACTION_MANIFESTS { + return Err(IdentityError::limit( + "provider compaction manifests", + state.compaction_manifests.len().saturating_add(1), + MAX_PROVIDER_COMPACTION_MANIFESTS, + )); + } + let mut staged = state.clone(); + staged.compaction_manifests.push(manifest.clone()); + staged.validate_cached()?; + let staged_accounting = + (*portable_accounting).with_appended_compaction_manifest(&manifest)?; + *state = staged; + *portable_accounting = staged_accounting; + Ok(manifest) + } + + /// Irreversibly seal this generation after an exact verified full-mirror comparison. + /// + /// Sealing retains only inventory-mandated original-index records locally. The mirror is the + /// sole complete archive; sealed generations never accept additional appends. + pub fn seal_after_verified_mirror( + &self, + authorization: &ProviderCompactionAuthorization, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, + ) -> Result { + let mut state = self.lock_state()?; + seal_generation_state(&mut state, authorization, mirror, inventory) + } + + /// Atomically append or re-observe one admitted subject and issue an inclusion receipt. + pub fn append( + &self, + permit: ProviderAppendPermit, + observed_at: Timestamp, + signer: &S, + ) -> Result { + let ProviderAppendPermit { admission, request } = permit; + request.validate_for(&admission)?; + let _charged_bytes = request.encoded_bytes(); + admission.validate_observed_at(observed_at)?; + let checkpoint_bundle = admission.checkpoint_bundle().cloned(); + if let Some(bundle) = checkpoint_bundle.as_ref() { + interchange::validate_checkpoint_bundle_interchange_item(bundle)?; + } + let mut state = self.lock_state()?; + let mut portable_accounting = self.lock_portable_accounting()?; + if matches!(state.payload, ProviderGenerationPayload::Sealed(_)) { + return Err(IdentityError::ProviderArchiveRequired); + } + if state + .latest_head + .as_ref() + .is_some_and(|head| observed_at < head.body().observed_at()) + { + return Err(IdentityError::ProviderRollback); + } + + let duplicate_index = state.active()?.entries.iter().position(|entry| { + entry.account_id() == admission.account_id() && entry.subject() == admission.subject() + }); + let duplicate_bundle_merge = if let Some(index) = duplicate_index { + merge_duplicate_bundle(&state, index, checkpoint_bundle.as_ref())? + } else if let Some(bundle) = checkpoint_bundle.as_ref() { + validate_checkpoint_admission(&state, bundle)?; + None + } else { + None + }; + let mut staged_entries = state.active()?.entries.clone(); + let mut staged_tree = state.tree()?; + let mut staged_checkpoint_bundles = state.active()?.checkpoint_bundles.clone(); + let mut staged_accounting = *portable_accounting; + if let Some((index, merged)) = duplicate_bundle_merge { + interchange::validate_checkpoint_bundle_interchange_item(&merged)?; + let retained = staged_checkpoint_bundles + .get_mut(index) + .ok_or(IdentityError::StorageCorruption)?; + staged_accounting = + staged_accounting.with_replaced_checkpoint_bundle(retained, &merged)?; + *retained = merged; + } + let leaf_index = match duplicate_index { + Some(index) => u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider log duplicate index", + })?, + None => { + if staged_entries.len() == MAX_MERKLE_LOG_LEAVES { + return Err(IdentityError::limit( + "provider log entries", + staged_entries.len().saturating_add(1), + MAX_MERKLE_LOG_LEAVES, + )); + } + let entry = ProviderLogEntryBody::new( + state.provider.id()?, + state.log_id, + admission.account_id(), + admission.subject(), + observed_at, + Extensions::default(), + )?; + let index = staged_tree.append(entry.merkle_leaf_hash()?)?; + staged_entries.push(entry); + if let Some(bundle) = checkpoint_bundle.clone() { + staged_checkpoint_bundles.push(bundle); + } + index + } + }; + let staged_checkpoint_index = + rebuild_checkpoint_index(&staged_entries, &staged_checkpoint_bundles)?; + + let head_body = ProviderHeadBody::new( + state.provider.id()?, + state.log_id, + state.key_version, + staged_tree.tree_size()?, + staged_tree.root()?, + observed_at, + Extensions::default(), + )?; + let signature = signer.sign_provider_head(&head_body.signing_bytes()?)?; + let signed_head = SignedProviderHead::new(head_body, signature); + signed_head.verify(&state.provider)?; + let entry_index = + usize::try_from(leaf_index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider log receipt index", + })?; + let entry = staged_entries + .get(entry_index) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + let receipt = InclusionReceipt::new( + entry, + leaf_index, + staged_tree + .inclusion_proof(leaf_index)? + .audit_path() + .to_vec(), + signed_head.clone(), + )?; + receipt.verify(&state.provider)?; + + let mut staged_receipts = state.active()?.receipts.clone(); + match duplicate_index { + Some(index) => { + let previous = state + .active()? + .receipts + .get(index) + .ok_or(IdentityError::StorageCorruption)?; + staged_accounting = staged_accounting.with_replaced_receipt(previous, &receipt)?; + let retained = staged_receipts + .get_mut(index) + .ok_or(IdentityError::StorageCorruption)?; + *retained = receipt.clone(); + } + None => { + let appended_entry = staged_entries + .last() + .ok_or(IdentityError::StorageCorruption)?; + let appended_leaf_hash = staged_tree + .leaf_hashes() + .last() + .ok_or(IdentityError::StorageCorruption)?; + staged_accounting = staged_accounting + .with_appended_entry(appended_entry)? + .with_appended_leaf_hash(appended_leaf_hash)? + .with_appended_receipt(&receipt)?; + if checkpoint_bundle.is_some() { + let appended_bundle = staged_checkpoint_bundles + .last() + .ok_or(IdentityError::StorageCorruption)?; + staged_accounting = + staged_accounting.with_appended_checkpoint_bundle(appended_bundle)?; + } + staged_receipts.push(receipt.clone()); + } + } + + state.active_mut()?.entries = staged_entries; + state.leaf_hashes = staged_tree.leaf_hashes().to_vec(); + state.latest_head = Some(signed_head); + state.active_mut()?.checkpoint_bundles = staged_checkpoint_bundles; + state.active_mut()?.checkpoint_index = staged_checkpoint_index; + state.active_mut()?.receipts = staged_receipts; + *portable_accounting = staged_accounting; + Ok(receipt) + } + + /// Return a bounded account-filtered page with a provider-wide continuation cursor. + pub fn account_history( + &self, + account_id: AccountId, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> Result { + if maximum_records == 0 || maximum_records > MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "provider account-history records", + maximum_records, + MAX_HISTORY_PAGE_EVENTS, + )); + } + if maximum_bytes == 0 || maximum_bytes > MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES { + return Err(IdentityError::limit( + "provider account-history bytes", + maximum_bytes, + MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES, + )); + } + let state = self.lock_state()?; + state.validate_cached()?; + let entries = match &state.payload { + ProviderGenerationPayload::Active(active) => active.entries.clone(), + ProviderGenerationPayload::Sealed(sealed) if sealed.archive_complete => sealed + .retained_records + .iter() + .map(|record| record.entry.clone()) + .collect(), + ProviderGenerationPayload::Sealed(_) => { + return Err(IdentityError::ProviderArchiveRequired); + } + }; + let start = match after_cursor { + None => 0, + Some(cursor) => usize::try_from(cursor) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider account-history cursor", + })? + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider account-history cursor", + })?, + }; + if start > entries.len() { + return Err(IdentityError::InvalidRelationship { + resource: "provider account-history cursor", + }); + } + + let mut records = Vec::new(); + let mut cursor = after_cursor; + let mut exhausted = true; + for (index, entry) in entries.iter().enumerate().skip(start) { + if entry.account_id() == account_id { + if records.len() == maximum_records { + exhausted = false; + break; + } + let leaf_index = + u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider account-history leaf index", + })?; + records.push(ProviderAccountHistoryRecord { + leaf_index, + entry: entry.clone(), + }); + let encoded = crate::codec::encode_wire(&(records.as_slice(), Some(leaf_index)))?; + if encoded.len() > maximum_bytes { + records.pop(); + if records.is_empty() { + return Err(IdentityError::limit( + "provider account-history bytes", + encoded.len(), + maximum_bytes, + )); + } + exhausted = false; + break; + } + } + cursor = Some( + u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider account-history cursor", + })?, + ); + } + Ok(ProviderAccountHistoryPage { + records, + next_cursor: if exhausted { None } else { cursor }, + }) + } + + /// Produce a complete bounded export suitable for verified recovery or mirroring. + pub fn export_generation(&self) -> Result { + let state = self.lock_state()?; + state.validate_cached()?; + state.export() + } + + /// Bind this complete active generation or recovery archive to an exact audit snapshot. + pub fn export_recovery( + &self, + audit: ProviderAuditSnapshot, + ) -> Result { + ProviderRecoveryExport::new(self.export_generation()?, audit) + } + + /// Re-export the complete generation and audit journal from a restored immutable archive. + pub fn archived_recovery_export(&self) -> Result { + let state = self.lock_state()?; + state.validate_cached()?; + let ProviderGenerationPayload::Sealed(sealed) = &state.payload else { + return Err(IdentityError::InvalidRelationship { + resource: "provider recovery archive state", + }); + }; + if !sealed.archive_complete { + return Err(IdentityError::ProviderArchiveRequired); + } + let audit = sealed + .audit_snapshot + .clone() + .ok_or(IdentityError::StorageCorruption)?; + ProviderRecoveryExport::new(state.export()?, audit) + } + + /// Return the complete audit history retained by a restored immutable archive. + pub fn archived_audit_snapshot(&self) -> Result { + let state = self.lock_state()?; + state.validate_cached()?; + match &state.payload { + ProviderGenerationPayload::Sealed(sealed) if sealed.archive_complete => sealed + .audit_snapshot + .clone() + .ok_or(IdentityError::StorageCorruption), + ProviderGenerationPayload::Sealed(_) => Err(IdentityError::ProviderArchiveRequired), + ProviderGenerationPayload::Active(_) => Err(IdentityError::InvalidRelationship { + resource: "provider recovery archive state", + }), + } + } + + /// Restore only after validating the complete export against its authenticated head. + pub fn restore_generation(export: ProviderGenerationExport) -> Result { + let portable_accounting = + interchange::ProviderGenerationPortableAccounting::from_export(&export)?; + let state = ProviderGenerationState { + provider: export.provider, + log_id: export.log_id, + key_version: export.key_version, + leaf_hashes: export.leaf_hashes, + latest_head: export.latest_head, + compaction_manifests: export.compaction_manifests, + payload: ProviderGenerationPayload::Active(ActiveProviderPayload { + entries: export.entries, + receipts: export.receipts, + checkpoint_bundles: export.checkpoint_bundles, + checkpoint_index: Vec::new(), + }), + }; + let checkpoint_index = rebuild_checkpoint_index( + &state.active()?.entries, + &state.active()?.checkpoint_bundles, + )?; + let mut state = state; + state.active_mut()?.checkpoint_index = checkpoint_index; + state.validate()?; + Ok(Self { + state: Arc::new(Mutex::new(state)), + portable_accounting: Arc::new(Mutex::new(portable_accounting)), + }) + } + + /// Restore a complete recovery export as an immutable local archive. + pub fn restore_recovery(recovery: ProviderRecoveryExport) -> Result { + let state = recovery_archive_state(recovery)?; + let portable_accounting = + interchange::ProviderGenerationPortableAccounting::from_export(&state.export()?)?; + Ok(Self { + state: Arc::new(Mutex::new(state)), + portable_accounting: Arc::new(Mutex::new(portable_accounting)), + }) + } + + /// Serve consistency evidence for an exact historical prefix and requested later prefix. + pub fn consistency_proof( + &self, + old_size: u64, + new_size: u64, + ) -> Result { + if old_size > new_size { + return Err(IdentityError::InvalidProof); + } + let state = self.lock_state()?; + let new_len = usize::try_from(new_size).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider consistency tree size", + })?; + if new_len > state.leaf_hashes.len() { + return Err(IdentityError::InvalidProof); + } + AppendOnlyMerkleLog::from_leaf_hashes(state.leaf_hashes[..new_len].to_vec())? + .consistency_proof(old_size) + } + + fn lock_state(&self) -> Result, IdentityError> { + self.state + .lock() + .map_err(|_| IdentityError::StorageCorruption) + } + + fn lock_portable_accounting( + &self, + ) -> Result, IdentityError> + { + self.portable_accounting + .lock() + .map_err(|_| IdentityError::StorageCorruption) + } +} + +impl AddressedProviderGeneration for MemoryProviderStore { + fn generation_route(&self) -> Result { + Self::generation_route(self) + } +} + +fn recovery_archive_state( + recovery: ProviderRecoveryExport, +) -> Result { + recovery.validate()?; + let ProviderRecoveryExport { + generation, + audit, + artifacts, + .. + } = recovery; + let checkpoint_index = + rebuild_checkpoint_index(&generation.entries, &generation.checkpoint_bundles)?; + let mut retained_records = Vec::with_capacity(generation.entries.len()); + for (index, (entry, receipt)) in generation + .entries + .into_iter() + .zip(generation.receipts) + .enumerate() + { + retained_records.push(RetainedProviderRecord { + leaf_index: u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider recovery archive leaf index", + })?, + entry, + receipt, + }); + } + let state = ProviderGenerationState { + provider: generation.provider, + log_id: generation.log_id, + key_version: generation.key_version, + leaf_hashes: generation.leaf_hashes, + latest_head: generation.latest_head, + compaction_manifests: generation.compaction_manifests, + payload: ProviderGenerationPayload::Sealed(Box::new(SealedProviderPayload { + retained_records, + checkpoint_bundles: generation.checkpoint_bundles, + retained_checkpoint_evidence: Vec::new(), + checkpoint_index, + manifest: None, + inventory: None, + audit_snapshot: Some(audit), + audit_artifacts: artifacts, + archive_complete: true, + })), + }; + state.validate()?; + Ok(state) +} + +fn seal_generation_state( + state: &mut ProviderGenerationState, + authorization: &ProviderCompactionAuthorization, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, +) -> Result { + state.validate()?; + if let ProviderGenerationPayload::Sealed(sealed) = &state.payload { + if sealed.archive_complete { + return Err(IdentityError::ProviderArchiveRequired); + } + if sealed.manifest.as_ref() != Some(authorization.manifest()) + || sealed.inventory.as_ref() != Some(inventory) + { + return Err(IdentityError::InvalidProof); + } + authorization.manifest().verify(mirror, mirror, inventory)?; + let retained = sealed.retained_records.len(); + let source_size = usize::try_from(authorization.manifest().source_tree_size()) + .map_err(|_| IdentityError::StorageCorruption)?; + return source_size + .checked_sub(retained) + .ok_or(IdentityError::StorageCorruption); + } + let mut source = state.export()?; + source + .compaction_manifests + .retain(|candidate| candidate != authorization.manifest()); + if &source != mirror.generation() { + return Err(IdentityError::InvalidProof); + } + authorization.manifest().verify(mirror, mirror, inventory)?; + let retained = derive_retained_provider_material(mirror.generation(), inventory)?; + let released = source + .entries + .len() + .checked_sub(retained.records.len()) + .ok_or(IdentityError::StorageCorruption)?; + let manifest = authorization.manifest().clone(); + let mut staged = state.clone(); + if !staged.compaction_manifests.contains(&manifest) { + if staged.compaction_manifests.len() == MAX_PROVIDER_COMPACTION_MANIFESTS { + return Err(IdentityError::limit( + "provider compaction manifests", + staged.compaction_manifests.len().saturating_add(1), + MAX_PROVIDER_COMPACTION_MANIFESTS, + )); + } + staged.compaction_manifests.push(manifest.clone()); + } + staged.payload = ProviderGenerationPayload::Sealed(Box::new(SealedProviderPayload { + retained_records: retained.records, + checkpoint_bundles: Vec::new(), + retained_checkpoint_evidence: retained.checkpoint_evidence, + checkpoint_index: retained.checkpoint_index, + manifest: Some(manifest), + inventory: Some(inventory.clone()), + audit_snapshot: None, + audit_artifacts: inventory.audit_artifacts().to_vec(), + archive_complete: false, + })); + staged.validate()?; + *state = staged; + Ok(released) +} + +fn encoded_admission_bytes(admission: &ProviderLogAdmission) -> Result { + let bytes = match admission.checkpoint_bundle() { + Some(bundle) => { + let checkpoint = bundle.verified_checkpoint(); + crate::codec::encode_wire(&( + admission.account_id(), + admission.subject(), + bundle.genesis(), + bundle.prior_checkpoint_id(), + bundle.events(), + checkpoint.checkpoint(), + checkpoint.transition_event(), + ))? + } + None => crate::codec::encode_wire(&(admission.account_id(), admission.subject()))?, + }; + if bytes.len() > MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES { + return Err(IdentityError::limit( + "provider append admission bytes", + bytes.len(), + MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES, + )); + } + Ok(bytes.len()) +} + +fn merge_duplicate_bundle( + state: &ProviderGenerationState, + entry_index: usize, + candidate: Option<&ProviderCheckpointBundle>, +) -> Result, IdentityError> { + let entry = state + .active()? + .entries + .get(entry_index) + .ok_or(IdentityError::StorageCorruption)?; + match (entry.subject(), candidate) { + (ProviderLogSubject::Checkpoint(checkpoint_id), Some(candidate)) => { + let (bundle_index, retained) = state + .active()? + .checkpoint_bundles + .iter() + .enumerate() + .find(|bundle| { + let checkpoint = bundle.1.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == entry.account_id() + && checkpoint.checkpoint_id() == checkpoint_id + }) + .ok_or(IdentityError::StorageCorruption)?; + let merged = retained.merge_approval_evidence(candidate)?; + Ok(Some((bundle_index, merged))) + } + (ProviderLogSubject::Checkpoint(_), None) + | (ProviderLogSubject::EventIntent(_), Some(_)) => { + Err(IdentityError::InvalidRelationship { + resource: "provider duplicate admission material", + }) + } + (ProviderLogSubject::EventIntent(_), None) => Ok(None), + } +} + +fn validate_checkpoint_admission( + state: &ProviderGenerationState, + bundle: &ProviderCheckpointBundle, +) -> Result<(), IdentityError> { + let checkpoint = bundle.verified_checkpoint(); + let body = checkpoint.checkpoint().body(); + if let Some(prior_checkpoint_id) = bundle.prior_checkpoint_id() { + let prior_retained = state.active()?.checkpoint_bundles.iter().any(|candidate| { + let prior = candidate.verified_checkpoint(); + prior.checkpoint().body().account_id() == body.account_id() + && prior.checkpoint_id() == prior_checkpoint_id + }); + if !prior_retained { + return Err(IdentityError::InvalidProof); + } + } + if let Some(index) = state + .active()? + .checkpoint_index + .iter() + .find(|index| index.account_id == body.account_id()) + && (body.sequence() < index.greatest_sequence + || body.account_epoch() < index.greatest_epoch) + { + return Err(IdentityError::ProviderRollback); + } + Ok(()) +} + +fn latest_checkpoint_bundle( + state: &ProviderGenerationState, + account_id: AccountId, +) -> Result, IdentityError> { + match &state.payload { + ProviderGenerationPayload::Active(payload) => select_current_checkpoint_bundle( + &payload.checkpoint_index, + &payload.checkpoint_bundles, + account_id, + ), + ProviderGenerationPayload::Sealed(payload) if payload.archive_complete => { + select_current_checkpoint_bundle( + &payload.checkpoint_index, + &payload.checkpoint_bundles, + account_id, + ) + } + ProviderGenerationPayload::Sealed(_) => Err(IdentityError::ProviderArchiveRequired), + } +} + +fn published_checkpoint_for( + state: &ProviderGenerationState, + account_id: AccountId, + checkpoint_id: CheckpointId, +) -> Result, IdentityError> { + if let ProviderGenerationPayload::Sealed(sealed) = &state.payload { + if !sealed.archive_complete { + return Err(IdentityError::ProviderArchiveRequired); + } + let mut matches = sealed.retained_records.iter().filter(|record| { + record.entry.account_id() == account_id + && record.entry.subject() == ProviderLogSubject::Checkpoint(checkpoint_id) + }); + let Some(record) = matches.next() else { + return Err(IdentityError::ProviderArchiveRequired); + }; + if matches.next().is_some() { + return Err(IdentityError::StorageCorruption); + } + let bundle = sealed + .checkpoint_bundles + .iter() + .find(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == account_id + && checkpoint.checkpoint_id() == checkpoint_id + }) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + return PublishedCheckpoint::new(bundle, record.receipt.clone(), &state.provider).map(Some); + } + let mut bundle_matches = state.active()?.checkpoint_bundles.iter().filter(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == account_id + && checkpoint.checkpoint_id() == checkpoint_id + }); + let Some(bundle) = bundle_matches.next() else { + return Ok(None); + }; + if bundle_matches.next().is_some() { + return Err(IdentityError::StorageCorruption); + } + let mut entry_matches = state + .active()? + .entries + .iter() + .enumerate() + .filter(|(_, entry)| { + entry.account_id() == account_id + && entry.subject() == ProviderLogSubject::Checkpoint(checkpoint_id) + }); + let (entry_index, _) = entry_matches + .next() + .ok_or(IdentityError::StorageCorruption)?; + if entry_matches.next().is_some() { + return Err(IdentityError::StorageCorruption); + } + let receipt = state + .active()? + .receipts + .get(entry_index) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + PublishedCheckpoint::new(bundle.clone(), receipt, &state.provider).map(Some) +} + +fn retained_checkpoint_evidence_for( + state: &ProviderGenerationState, + account_id: AccountId, + checkpoint_id: CheckpointId, +) -> Result, IdentityError> { + let ProviderGenerationPayload::Sealed(sealed) = &state.payload else { + return Ok(None); + }; + if sealed.archive_complete { + return Ok(None); + } + let mut matches = sealed.retained_records.iter().filter(|record| { + record.entry.account_id() == account_id + && record.entry.subject() == ProviderLogSubject::Checkpoint(checkpoint_id) + }); + let Some(record) = matches.next() else { + return Err(IdentityError::ProviderArchiveRequired); + }; + if matches.next().is_some() { + return Err(IdentityError::StorageCorruption); + } + let mut evidence = sealed + .retained_checkpoint_evidence + .iter() + .filter(|evidence| { + evidence.checkpoint.body().account_id() == account_id + && evidence.checkpoint_id() == Ok(checkpoint_id) + }); + let material = evidence + .next() + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + if evidence.next().is_some() { + return Err(IdentityError::StorageCorruption); + } + Ok(Some(ProviderRetainedCheckpointEvidence { + material, + receipt: record.receipt.clone(), + })) +} + +fn checkpoint_lineage_page_for( + state: &ProviderGenerationState, + account_id: AccountId, + start_checkpoint_id: CheckpointId, + maximum_records: usize, + maximum_bytes: usize, +) -> Result, IdentityError> { + if maximum_records == 0 || maximum_bytes == 0 { + return Err(IdentityError::limit( + "provider checkpoint-lineage page", + 0, + 1, + )); + } + let record_limit = maximum_records.min(MAX_HISTORY_PAGE_EVENTS); + let byte_limit = maximum_bytes.min(MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES); + let Some(first) = published_checkpoint_for(state, account_id, start_checkpoint_id)? else { + return Ok(None); + }; + let mut checkpoints = Vec::new(); + let mut total_bytes = 0_usize; + let mut current = Some(first); + let mut seen = std::collections::BTreeSet::new(); + while let Some(checkpoint) = current { + let checkpoint_id = checkpoint.bundle().verified_checkpoint().checkpoint_id(); + if !seen.insert(checkpoint_id) { + return Err(IdentityError::InvalidProof); + } + let encoded_bytes = crate::publication::encoded_published_checkpoint_bytes(&checkpoint)?; + let next_total = + total_bytes + .checked_add(encoded_bytes) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider checkpoint-lineage bytes", + })?; + if checkpoints.len() == record_limit || next_total > byte_limit { + if checkpoints.is_empty() { + return Err(IdentityError::limit( + "provider checkpoint-lineage bytes", + encoded_bytes, + byte_limit, + )); + } + let next_prior_checkpoint_id = checkpoints + .last() + .and_then(|retained: &PublishedCheckpoint| retained.bundle().prior_checkpoint_id()); + return ProviderCheckpointLineagePage::new( + account_id, + start_checkpoint_id, + checkpoints, + next_prior_checkpoint_id, + &state.provider, + state.log_id, + ) + .map(Some); + } + total_bytes = next_total; + let prior_checkpoint_id = checkpoint.bundle().prior_checkpoint_id(); + checkpoints.push(checkpoint); + current = match prior_checkpoint_id { + None => None, + Some(prior_checkpoint_id) => { + published_checkpoint_for(state, account_id, prior_checkpoint_id)? + .ok_or(IdentityError::InvalidProof) + .map(Some)? + } + }; + } + ProviderCheckpointLineagePage::new( + account_id, + start_checkpoint_id, + checkpoints, + None, + &state.provider, + state.log_id, + ) + .map(Some) +} + +fn select_current_checkpoint_bundle<'a>( + checkpoint_index: &[ProviderCheckpointIndex], + bundles: &'a [ProviderCheckpointBundle], + account_id: AccountId, +) -> Result, IdentityError> { + let Some(index) = checkpoint_index + .iter() + .find(|index| index.account_id == account_id) + else { + return Ok(None); + }; + if index.forked { + return Err(IdentityError::AccountForked); + } + let checkpoint_id = index + .current_checkpoint_id + .ok_or(IdentityError::StorageCorruption)?; + bundles + .iter() + .rev() + .find(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == account_id + && checkpoint.checkpoint_id() == checkpoint_id + }) + .map(Some) + .ok_or(IdentityError::StorageCorruption) +} + +/// Rebuild and reverify the exact per-account monotonic checkpoint index. +pub(crate) fn rebuild_checkpoint_index( + entries: &[ProviderLogEntryBody], + bundles: &[ProviderCheckpointBundle], +) -> Result, IdentityError> { + let mut lineage = BTreeMap::::new(); + for bundle in bundles { + let verified = bundle.verified_checkpoint(); + let body = verified.checkpoint().body(); + let base_state = match (bundle.genesis(), bundle.prior_checkpoint_id()) { + (Some(genesis), None) => AccountState::from_genesis(genesis)?, + (None, Some(prior_checkpoint_id)) => lineage + .get(&prior_checkpoint_id) + .filter(|(prior, _)| prior.checkpoint().body().account_id() == body.account_id()) + .map(|(_, state)| state.clone()) + .ok_or(IdentityError::StorageCorruption)?, + (Some(_), Some(_)) | (None, None) => return Err(IdentityError::StorageCorruption), + }; + let (projected, _) = project_bundle_state(base_state, bundle)?; + let rebuilt = match (bundle.genesis(), bundle.prior_checkpoint_id()) { + (Some(genesis), None) => build_provider_checkpoint_bundle_from_genesis( + genesis, + bundle.events(), + verified.checkpoint(), + verified.transition_event(), + ), + (None, Some(prior_checkpoint_id)) => { + let (prior, prior_state) = lineage + .get(&prior_checkpoint_id) + .ok_or(IdentityError::StorageCorruption)?; + build_provider_checkpoint_bundle_from_prior( + prior_state, + prior, + bundle.events(), + verified.checkpoint(), + verified.transition_event(), + ) + } + (Some(_), Some(_)) | (None, None) => return Err(IdentityError::StorageCorruption), + } + .map_err(|_| IdentityError::StorageCorruption)?; + if &rebuilt != bundle + || lineage + .insert(verified.checkpoint_id(), (verified.clone(), projected)) + .is_some() + { + return Err(IdentityError::StorageCorruption); + } + } + + let checkpoint_entries = entries + .iter() + .filter_map(|entry| match entry.subject() { + ProviderLogSubject::Checkpoint(checkpoint_id) => { + Some((entry.account_id(), checkpoint_id)) + } + ProviderLogSubject::EventIntent(_) => None, + }) + .collect::>(); + if checkpoint_entries.len() != bundles.len() + || checkpoint_entries + .iter() + .any(|(account_id, checkpoint_id)| { + !bundles.iter().any(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == *account_id + && checkpoint.checkpoint_id() == *checkpoint_id + }) + }) + { + return Err(IdentityError::StorageCorruption); + } + rebuild_account_projections(bundles) +} + +fn rebuild_account_projections( + bundles: &[ProviderCheckpointBundle], +) -> Result, IdentityError> { + let mut geneses = BTreeMap::::new(); + let mut events = BTreeMap::>::new(); + let mut counters = BTreeMap::::new(); + for bundle in bundles { + let checkpoint = bundle.verified_checkpoint(); + let body = checkpoint.checkpoint().body(); + let account_id = body.account_id(); + if let Some(genesis) = bundle.genesis() { + match geneses.get(&account_id) { + Some(retained) if retained != genesis => { + return Err(IdentityError::StorageCorruption); + } + Some(_) => {} + None => { + geneses.insert(account_id, genesis.clone()); + } + } + } + let account_events = events.entry(account_id).or_default(); + for event in bundle.events() { + let event_id = event.event_id()?; + match account_events.get(&event_id) { + Some(retained) if retained.body() != event.body() => { + return Err(IdentityError::StorageCorruption); + } + Some(_) => {} + None => { + account_events.insert(event_id, event.clone()); + } + } + } + match counters.get_mut(&account_id) { + Some((greatest_sequence, greatest_epoch)) => { + if body.sequence() < *greatest_sequence || body.account_epoch() < *greatest_epoch { + return Err(IdentityError::StorageCorruption); + } + *greatest_sequence = (*greatest_sequence).max(body.sequence()); + *greatest_epoch = (*greatest_epoch).max(body.account_epoch()); + } + None => { + counters.insert(account_id, (body.sequence(), body.account_epoch())); + } + } + } + + let mut index = Vec::with_capacity(counters.len()); + for (account_id, (greatest_sequence, greatest_epoch)) in counters { + let genesis = geneses + .get(&account_id) + .ok_or(IdentityError::StorageCorruption)?; + let mut state = AccountState::from_genesis(genesis)?; + let mut ordered = events + .remove(&account_id) + .unwrap_or_default() + .into_iter() + .map(|(event_id, event)| (event.body().sequence(), event_id, event)) + .collect::>(); + ordered.sort_unstable_by_key(|(sequence, event_id, _)| (*sequence, *event_id)); + for (_, _, event) in ordered { + match state.validate_and_apply(&event)?.disposition() { + ApplyDisposition::Applied | ApplyDisposition::ForkDetected => {} + ApplyDisposition::Replay | ApplyDisposition::ApprovalsMerged => { + return Err(IdentityError::StorageCorruption); + } + } + } + let forked = state.lifecycle() == ProjectionLifecycle::Forked; + let current_checkpoint_id = if forked { + None + } else { + bundles + .iter() + .rev() + .filter(|bundle| { + bundle + .verified_checkpoint() + .checkpoint() + .body() + .account_id() + == account_id + }) + .find_map(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + let body = checkpoint.checkpoint().body(); + match build_checkpoint_body(&state, body.issued_at()) { + Ok(expected) if expected == *body => Some(checkpoint.checkpoint_id()), + Ok(_) | Err(_) => None, + } + }) + .ok_or(IdentityError::StorageCorruption)? + .into() + }; + index.push(ProviderCheckpointIndex { + account_id, + greatest_sequence, + greatest_epoch, + current_checkpoint_id, + projection_heads: state.heads().to_vec(), + forked, + }); + } + Ok(index) +} + +/// Select one current provider-served checkpoint using the shared fork/lineage semantics. +pub(crate) fn current_checkpoint_bundle<'a>( + entries: &[ProviderLogEntryBody], + bundles: &'a [ProviderCheckpointBundle], + account_id: AccountId, +) -> Result, IdentityError> { + let checkpoint_index = rebuild_checkpoint_index(entries, bundles)?; + select_current_checkpoint_bundle(&checkpoint_index, bundles, account_id) +} + +fn project_bundle_state( + mut state: AccountState, + bundle: &ProviderCheckpointBundle, +) -> Result<(AccountState, bool), IdentityError> { + let mut observed_fork = false; + for event in bundle.events() { + match state.validate_and_apply(event)?.disposition() { + ApplyDisposition::Applied => {} + ApplyDisposition::ForkDetected => observed_fork = true, + ApplyDisposition::Replay | ApplyDisposition::ApprovalsMerged => { + return Err(IdentityError::InvalidRelationship { + resource: "provider checkpoint advancing event chain", + }); + } + } + } + if state.lifecycle() == ProjectionLifecycle::Forked { + return Err(IdentityError::AccountForked); + } + Ok((state, observed_fork)) +} + +#[cfg(test)] +mod tests { + use krikos_base::SecretKey; + + use super::*; + use crate::{ + CanonicalWire, HashAlgorithm, ProposalId, ProtocolSignature, ProviderAuditArtifactKind, + ProviderHeadAuditDisposition, SigningPublicKey, + audit::{DurableProviderAuditor, MemoryProviderAuditStore}, + }; + + #[derive(serde::Serialize)] + struct AuditArtifactCommitmentMirror<'a> { + format_version: u16, + sequence: u64, + kind_code: u16, + accepted_head: &'a SignedProviderHead, + observed_head: &'a SignedProviderHead, + } + + #[derive(serde::Serialize)] + struct AuditArtifactSetCommitmentMirror<'a> { + format_version: u16, + artifact_commitments: &'a [Digest], + } + + #[derive(serde::Serialize)] + struct RecoveryCommitmentMirror { + format_version: u16, + generation_commitment: Digest, + audit_commitment: Digest, + artifact_commitment: Digest, + } + + struct Signer(SecretKey); + + impl ProviderHeadSigner for Signer { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } + } + + fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() + } + + fn raw_commitment(domain: &[u8], value: &T) -> Digest { + let bytes = postcard::to_stdvec(value).unwrap(); + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(&bytes); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) + } + + fn indexed_id(domain: u8, index: u16) -> T { + let mut bytes = [domain; 32]; + bytes[..2].copy_from_slice(&index.to_le_bytes()); + let digest = Digest::new(HashAlgorithm::Blake3_256, bytes); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() + } + + #[test] + fn repeated_appends_encode_only_the_changed_portable_items() { + const APPEND_COUNT: u16 = 257; + + let signer = Signer(SecretKey::from_bytes(&[0xb1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let store = MemoryProviderStore::new( + provider, + typed_id::(0xb2), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + interchange::reset_portable_item_encoding_count(); + + for index in 0..APPEND_COUNT { + let observed_at = Timestamp::from_unix_millis(100); + let admission = ProviderLogAdmission::guardian_recovery_intent( + indexed_id::(0xb3, index), + indexed_id::(0xb4, index), + observed_at, + ); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + ProviderAppendPermit { admission, request }, + observed_at, + &signer, + ) + .unwrap(); + } + + assert_eq!( + interchange::portable_item_encoding_count(), + usize::from(APPEND_COUNT) * 3, + "each append must encode only its new entry, leaf hash, and receipt" + ); + } + + #[test] + fn archive_boundaries_replay_each_full_state_once() { + let signer = Signer(SecretKey::from_bytes(&[0xb5; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xb6); + let store = MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + let observed_at = Timestamp::from_unix_millis(700); + let admission = ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xb7), + typed_id::(0xb8), + observed_at, + ); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + ProviderAppendPermit { admission, request }, + observed_at, + &signer, + ) + .unwrap(); + let generation = store.export_generation().unwrap(); + let audit_store = MemoryProviderAuditStore::new(provider, log_id); + DurableProviderAuditor::new(audit_store.clone()) + .observe(generation.latest_head().unwrap().clone(), None) + .unwrap(); + let audit = audit_store.snapshot().unwrap(); + + reset_provider_generation_validation_count(); + crate::audit::reset_provider_audit_validation_count(); + let recovery = ProviderRecoveryExport::new(generation.clone(), audit.clone()).unwrap(); + assert_eq!(provider_generation_validation_count(), 1); + assert_eq!(crate::audit::provider_audit_validation_count(), 1); + + reset_provider_generation_validation_count(); + let (generation_manifest, generation_chunks) = generation.interchange_parts().unwrap(); + assert_eq!(provider_generation_validation_count(), 1); + let mut generation_assembler = + ProviderGenerationExportAssembler::new(generation_manifest).unwrap(); + for chunk in generation_chunks { + generation_assembler.insert(chunk).unwrap(); + } + reset_provider_generation_validation_count(); + assert_eq!(generation_assembler.finish().unwrap(), generation); + assert_eq!(provider_generation_validation_count(), 1); + + crate::audit::reset_provider_audit_validation_count(); + let (audit_manifest, audit_chunks) = audit.interchange_parts().unwrap(); + assert_eq!(crate::audit::provider_audit_validation_count(), 1); + let mut audit_assembler = ProviderAuditExportAssembler::new(audit_manifest).unwrap(); + for chunk in audit_chunks { + audit_assembler.insert(chunk).unwrap(); + } + crate::audit::reset_provider_audit_validation_count(); + assert_eq!(audit_assembler.finish().unwrap(), audit); + assert_eq!(crate::audit::provider_audit_validation_count(), 1); + + reset_provider_generation_validation_count(); + crate::audit::reset_provider_audit_validation_count(); + recovery.interchange_parts().unwrap(); + assert_eq!(provider_generation_validation_count(), 1); + assert_eq!(crate::audit::provider_audit_validation_count(), 1); + } + + #[test] + fn exact_guardian_observation_time_is_checked_before_memory_staging() { + let signer = Signer(SecretKey::from_bytes(&[0xa1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let store = MemoryProviderStore::new( + provider, + typed_id::(0xa2), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let admission = ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xa3), + typed_id::(0xa4), + Timestamp::from_unix_millis(50), + ); + let request = ProviderAdmissionRequest::new(128).unwrap(); + let wrong = ProviderAppendPermit { + admission: admission.clone(), + request, + }; + assert_eq!( + store.append(wrong, Timestamp::from_unix_millis(51), &signer), + Err(IdentityError::InvalidRelationship { + resource: "provider admission observation time", + }) + ); + assert_eq!(store.snapshot().unwrap().tree_size(), 0); + + let exact = ProviderAppendPermit { admission, request }; + store + .append(exact, Timestamp::from_unix_millis(50), &signer) + .unwrap(); + assert_eq!(store.snapshot().unwrap().tree_size(), 1); + } + + #[test] + fn provider_aggregate_commitments_use_versioned_canonical_preimages() { + let signer = Signer(SecretKey::from_bytes(&[0xc1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xc2); + let store = MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + let observed_at = Timestamp::from_unix_millis(500); + let admission = ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xc3), + typed_id::(0xc4), + observed_at, + ); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + ProviderAppendPermit { admission, request }, + observed_at, + &signer, + ) + .unwrap(); + let generation = store.export_generation().unwrap(); + let accepted = generation.latest_head().unwrap().clone(); + let conflict_body = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + accepted.body().tree_size(), + Digest::new(HashAlgorithm::Blake3_256, [0xc5; 32]), + Timestamp::from_unix_millis(501), + Extensions::default(), + ) + .unwrap(); + let conflict_signature = signer + .sign_provider_head(&conflict_body.signing_bytes().unwrap()) + .unwrap(); + let conflict = SignedProviderHead::new(conflict_body, conflict_signature); + let audit_store = MemoryProviderAuditStore::new(provider, log_id); + let auditor = DurableProviderAuditor::new(audit_store.clone()); + assert_eq!( + auditor.observe(accepted, None), + Ok(ProviderHeadAuditDisposition::FirstObserved) + ); + assert_eq!( + auditor.observe(conflict, None), + Err(IdentityError::ProviderEquivocation) + ); + let recovery = + ProviderRecoveryExport::new(generation.clone(), audit_store.snapshot().unwrap()) + .unwrap(); + assert_eq!(recovery.artifacts().len(), 1); + assert_eq!( + recovery.artifacts()[0].kind(), + ProviderAuditArtifactKind::Equivocation + ); + + let artifact_commitments = recovery + .artifacts() + .iter() + .map(|artifact| { + raw_commitment( + b"KRIKOS-ID/provider-audit-artifact/v1", + &AuditArtifactCommitmentMirror { + format_version: 1, + sequence: artifact.sequence(), + kind_code: 2, + accepted_head: artifact.accepted_head(), + observed_head: artifact.observed_head(), + }, + ) + }) + .collect::>(); + let expected_artifacts = raw_commitment( + b"KRIKOS-ID/provider-audit-artifacts/v1", + &AuditArtifactSetCommitmentMirror { + format_version: 1, + artifact_commitments: &artifact_commitments, + }, + ); + assert_eq!( + provider_audit_artifact_commitment(recovery.artifacts()).unwrap(), + expected_artifacts + ); + assert_eq!(recovery.artifact_commitment(), expected_artifacts); + + let expected_recovery = raw_commitment( + b"KRIKOS-ID/provider-recovery-export/v1", + &RecoveryCommitmentMirror { + format_version: 1, + generation_commitment: recovery.generation_commitment(), + audit_commitment: recovery.audit_commitment(), + artifact_commitment: expected_artifacts, + }, + ); + assert_eq!(recovery.recovery_commitment(), expected_recovery); + } +} diff --git a/protocols/krikos-identity/src/provider/anchor.rs b/protocols/krikos-identity/src/provider/anchor.rs new file mode 100644 index 00000000000..a16f2c791fd --- /dev/null +++ b/protocols/krikos-identity/src/provider/anchor.rs @@ -0,0 +1,145 @@ +//! Chain-neutral opaque provider anchoring boundary. + +use serde::{Deserialize, Serialize}; + +use super::{ProviderCompactionManifest, provider_commitment}; +use crate::{ + Digest, HashAlgorithm, IdentityError, StoreFuture, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, +}; + +const MAX_ANCHOR_EVIDENCE_BYTES: usize = 16 * 1024; +const PROVIDER_ANCHOR_COMMITMENT_DOMAIN: &[u8] = b"KRIKOS-ID/provider-anchor-commitment/v1"; + +/// Exact aggregate provider commitment exposed to an optional external anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct OpaqueProviderAnchorCommitment([u8; 32]); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct OpaqueProviderAnchorCommitmentWire { + format_version: u16, + commitment: [u8; 32], +} + +#[derive(Serialize)] +struct ProviderAnchorCommitmentPreimageWire<'a> { + format_version: u16, + manifest: &'a ProviderCompactionManifest, +} + +impl OpaqueProviderAnchorCommitment { + /// Derive the opaque commitment to one exact verified compaction manifest. + pub fn from_compaction_manifest( + manifest: &ProviderCompactionManifest, + ) -> Result { + manifest.validate_wire()?; + let commitment = provider_commitment( + PROVIDER_ANCHOR_COMMITMENT_DOMAIN, + &ProviderAnchorCommitmentPreimageWire { + format_version: 1, + manifest, + }, + )?; + Ok(Self(*commitment.as_bytes())) + } + + /// Exact opaque bytes; no account, device, guardian, or relationship identifier is exposed. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Algorithm-tagged digest form for local aggregate comparison. + pub const fn digest(self) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, self.0) + } +} + +impl CanonicalCodec for OpaqueProviderAnchorCommitment { + const RESOURCE: &'static str = "opaque provider anchor commitment bytes"; + const MAX_ENCODED_BYTES: usize = 64; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&OpaqueProviderAnchorCommitmentWire { + format_version: 1, + commitment: self.0, + }) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let wire: OpaqueProviderAnchorCommitmentWire = decode_wire(bytes)?; + if wire.format_version != 1 { + return Err(IdentityError::UnsupportedVersion { + version: wire.format_version, + }); + } + Ok(Self(wire.commitment)) + } +} + +/// Opaque backend evidence that commits to one exact aggregate provider commitment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderAnchorEvidence { + commitment: OpaqueProviderAnchorCommitment, + opaque_bytes: Vec, +} + +impl ProviderAnchorEvidence { + /// Attach bounded vendor-neutral evidence to the exact submitted commitment. + pub fn new( + commitment: OpaqueProviderAnchorCommitment, + opaque_bytes: Vec, + ) -> Result { + if opaque_bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "provider anchor evidence", + }); + } + if opaque_bytes.len() > MAX_ANCHOR_EVIDENCE_BYTES { + return Err(IdentityError::limit( + "provider anchor evidence bytes", + opaque_bytes.len(), + MAX_ANCHOR_EVIDENCE_BYTES, + )); + } + Ok(Self { + commitment, + opaque_bytes, + }) + } + + /// Exact aggregate commitment authenticated by this evidence. + pub const fn commitment(&self) -> OpaqueProviderAnchorCommitment { + self.commitment + } + + /// Bounded backend-defined status or inclusion bytes. + pub fn opaque_bytes(&self) -> &[u8] { + &self.opaque_bytes + } +} + +/// Typed status returned by an optional chain-neutral anchor backend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderAnchorStatus { + /// Submission is known but not yet durably included. + Pending, + /// Exact commitment has bounded backend inclusion evidence. + Included(ProviderAnchorEvidence), + /// Backend rejected the commitment with a nonzero stable operator code. + Rejected(u16), +} + +/// Optional anchor backend that receives only an opaque aggregate commitment. +pub trait ProviderAnchor: Send + Sync { + /// Submit one exact opaque aggregate commitment. + fn submit( + &self, + commitment: OpaqueProviderAnchorCommitment, + ) -> StoreFuture<'_, ProviderAnchorStatus>; + + /// Query status for one exact opaque aggregate commitment. + fn status( + &self, + commitment: OpaqueProviderAnchorCommitment, + ) -> StoreFuture<'_, ProviderAnchorStatus>; +} diff --git a/protocols/krikos-identity/src/provider/compaction.rs b/protocols/krikos-identity/src/provider/compaction.rs new file mode 100644 index 00000000000..a44265d99f4 --- /dev/null +++ b/protocols/krikos-identity/src/provider/compaction.rs @@ -0,0 +1,1034 @@ +//! Verified provider compaction manifests and retention inventories. + +use serde::{Deserialize, Serialize}; + +use super::{ + MemoryProviderStore, ProviderGenerationExport, ProviderRecoveryExport, + provider_audit_artifact_commitment, provider_commitment, provider_recovery_commitment, + rebuild_checkpoint_index, retained_provider_evidence_commitment, +}; +use crate::{ + AccountGenesis, AuthorizedEvent, CheckpointId, Digest, HashAlgorithm, IdentityError, + InclusionReceipt, OperationKind, ProviderAuditArtifact, ProviderDescriptor, ProviderId, + ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, SignedCheckpoint, + SignedProviderHead, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::MAX_MERKLE_LOG_LEAVES, + schema::BoundedVec, +}; + +const MAX_PROVIDER_AUDIT_ARTIFACTS: usize = 65_536; +const MAX_PROVIDER_COMPACTION_MANIFEST_BYTES: usize = 2 * 1024 * 1024; +const MAX_PROVIDER_WIRE_RETAINED_RANGES: usize = 4_096; +const PROVIDER_GENERATION_EXPORT_COMMITMENT_DOMAIN: &[u8] = + b"KRIKOS-ID/provider-generation-export/v1"; +const PROVIDER_RETENTION_INVENTORY_COMMITMENT_DOMAIN: &[u8] = + b"KRIKOS-ID/provider-retention-inventory/v1"; + +#[derive(Serialize)] +struct ProviderCheckpointBundleCommitmentWire<'a> { + genesis: Option<&'a AccountGenesis>, + prior_checkpoint_id: Option, + events: &'a [AuthorizedEvent], + checkpoint: &'a SignedCheckpoint, + transition_event: Option<&'a AuthorizedEvent>, +} + +#[derive(Serialize)] +struct ProviderGenerationExportCommitmentWire<'a> { + format_version: u16, + provider: &'a ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + entries: &'a [ProviderLogEntryBody], + leaf_hashes: &'a [Digest], + latest_head: Option<&'a SignedProviderHead>, + receipts: &'a [InclusionReceipt], + checkpoint_bundles: Vec>, + compaction_manifests: &'a [ProviderCompactionManifest], +} + +#[derive(Serialize)] +struct ProviderRetentionItemCommitmentWire { + leaf_index: u64, + class_code: u16, +} + +#[derive(Serialize)] +struct ProviderRetentionInventoryCommitmentWire { + format_version: u16, + tree_size: u64, + items: Vec, + audit_artifact_commitments: Vec, +} + +/// Evidence class explaining why a provider-log leaf must remain available after compaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProviderRetentionClass { + /// Current checkpoint tip, or every branch while the account remains forked. + CheckpointLineage, + /// Controller removal or retirement tombstone. + ControllerTombstone, + /// Device revocation or replacement tombstone. + DeviceTombstone, + /// Evidence belonging to an unresolved account fork. + UnresolvedFork, + /// Pending, dual, retired, or aborted cryptographic migration evidence. + CryptoMigration, + /// Pending or completed recovery lineage. + Recovery, + /// Provider signing-key or log-generation rotation evidence. + ProviderRotation, + /// Known signed provider equivocation evidence. + Equivocation, +} + +impl ProviderRetentionClass { + pub(crate) const fn code(self) -> u16 { + match self { + Self::CheckpointLineage => 1, + Self::ControllerTombstone => 2, + Self::DeviceTombstone => 3, + Self::UnresolvedFork => 4, + Self::CryptoMigration => 5, + Self::Recovery => 6, + Self::ProviderRotation => 7, + Self::Equivocation => 8, + } + } + + #[cfg(feature = "provider-store")] + pub(crate) fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::CheckpointLineage), + 2 => Ok(Self::ControllerTombstone), + 3 => Ok(Self::DeviceTombstone), + 4 => Ok(Self::UnresolvedFork), + 5 => Ok(Self::CryptoMigration), + 6 => Ok(Self::Recovery), + 7 => Ok(Self::ProviderRotation), + 8 => Ok(Self::Equivocation), + _ => Err(IdentityError::StorageCorruption), + } + } +} + +/// One exact leaf required by an authenticated retention class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ProviderRetentionItem { + leaf_index: u64, + class: ProviderRetentionClass, +} + +impl ProviderRetentionItem { + /// Name one bounded provider-wide leaf and its retention reason. + pub fn new(leaf_index: u64, class: ProviderRetentionClass) -> Result { + let index = usize::try_from(leaf_index).map_err(|_| IdentityError::LimitExceeded { + resource: "provider retention leaf index", + actual: usize::MAX, + maximum: MAX_MERKLE_LOG_LEAVES, + })?; + if index >= MAX_MERKLE_LOG_LEAVES { + return Err(IdentityError::limit( + "provider retention leaf index", + index.saturating_add(1), + MAX_MERKLE_LOG_LEAVES, + )); + } + Ok(Self { leaf_index, class }) + } + + /// Provider-wide zero-based leaf index. + pub const fn leaf_index(self) -> u64 { + self.leaf_index + } + + /// Authenticated reason this leaf remains retained. + pub const fn class(self) -> ProviderRetentionClass { + self.class + } +} + +/// Complete sorted retention inventory for one exact source tree size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderRetentionInventory { + tree_size: u64, + items: Vec, + audit_artifacts: Vec, +} + +impl ProviderRetentionInventory { + /// Validate, sort, and deduplicate the caller's complete retained-evidence inventory. + pub fn new(tree_size: u64, items: Vec) -> Result { + Self::with_audit_artifacts(tree_size, items, Vec::new()) + } + + /// Validate all leaf reasons and sorted non-leaf rollback/equivocation artifacts. + pub fn with_audit_artifacts( + tree_size: u64, + mut items: Vec, + mut audit_artifacts: Vec, + ) -> Result { + if tree_size > MAX_MERKLE_LOG_LEAVES as u64 { + return Err(IdentityError::limit( + "provider compaction tree size", + usize::try_from(tree_size).unwrap_or(usize::MAX), + MAX_MERKLE_LOG_LEAVES, + )); + } + items.sort_unstable(); + if items.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(IdentityError::DuplicateElement { + resource: "provider retention inventory", + }); + } + if items.iter().any(|item| item.leaf_index >= tree_size) { + return Err(IdentityError::InvalidRelationship { + resource: "provider retention leaf/tree size", + }); + } + if audit_artifacts.len() > MAX_PROVIDER_AUDIT_ARTIFACTS { + return Err(IdentityError::limit( + "provider retained audit artifacts", + audit_artifacts.len(), + MAX_PROVIDER_AUDIT_ARTIFACTS, + )); + } + audit_artifacts.sort_unstable_by_key(|artifact| (artifact.sequence(), artifact.kind())); + if audit_artifacts.windows(2).any(|pair| { + pair[0].sequence() == pair[1].sequence() && pair[0].kind() == pair[1].kind() + }) { + return Err(IdentityError::DuplicateElement { + resource: "provider retained audit artifacts", + }); + } + Ok(Self { + tree_size, + items, + audit_artifacts, + }) + } + + /// Exact source tree size governed by this inventory. + pub const fn tree_size(&self) -> u64 { + self.tree_size + } + + /// Sorted unique retained leaf/reason pairs. + pub fn items(&self) -> &[ProviderRetentionItem] { + &self.items + } + + /// Sorted exact rollback/equivocation evidence retained outside the provider leaf tree. + pub fn audit_artifacts(&self) -> &[ProviderAuditArtifact] { + &self.audit_artifacts + } +} + +/// One contiguous half-open provider leaf range retained locally after compaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderRetainedRange { + start: u64, + end_exclusive: u64, +} + +impl ProviderRetainedRange { + /// First retained provider-wide leaf index. + pub const fn start(self) -> u64 { + self.start + } + + /// First provider-wide leaf index outside this retained range. + pub const fn end_exclusive(self) -> u64 { + self.end_exclusive + } +} + +/// Authenticated manifest that must verify before any logical provider material is released. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderCompactionManifest { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + source_tree_size: u64, + source_tree_root: Digest, + archive_commitment: Digest, + generation_commitment: Digest, + audit_commitment: Digest, + audit_artifact_commitment: Digest, + inventory_commitment: Digest, + retained_evidence_commitment: Digest, + retained_ranges: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderCompactionManifestWire { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + source_tree_size: u64, + source_tree_root: Digest, + archive_commitment: Digest, + generation_commitment: Digest, + audit_commitment: Digest, + audit_artifact_commitment: Digest, + inventory_commitment: Digest, + retained_evidence_commitment: Digest, + retained_ranges: BoundedVec, +} + +impl ProviderCompactionManifest { + /// Version of the compaction manifest format. + pub const fn format_version(&self) -> u16 { + self.format_version + } + + /// Provider generation authenticated by this manifest. + pub const fn provider_id(&self) -> ProviderId { + self.provider_id + } + + /// Exact provider log generation authenticated by this manifest. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Exact provider key generation authenticated by this manifest. + pub const fn key_version(&self) -> ProviderKeyVersion { + self.key_version + } + + /// Complete pre-compaction tree size. + pub const fn source_tree_size(&self) -> u64 { + self.source_tree_size + } + + /// Complete pre-compaction tree root. + pub const fn source_tree_root(&self) -> Digest { + self.source_tree_root + } + + /// Exact full-mirror archive commitment. + pub const fn archive_commitment(&self) -> Digest { + self.archive_commitment + } + + /// Exact complete provider-generation component commitment. + pub const fn generation_commitment(&self) -> Digest { + self.generation_commitment + } + + /// Exact complete audit-journal component commitment. + pub const fn audit_commitment(&self) -> Digest { + self.audit_commitment + } + + /// Exact sorted rollback/equivocation artifact-set commitment. + pub const fn audit_artifact_commitment(&self) -> Digest { + self.audit_artifact_commitment + } + + /// Exact complete retention-inventory commitment. + pub const fn inventory_commitment(&self) -> Digest { + self.inventory_commitment + } + + /// Exact commitment of retained records, raw checkpoint material, projection index, and artifacts. + pub const fn retained_evidence_commitment(&self) -> Digest { + self.retained_evidence_commitment + } + + /// Coalesced local leaf ranges required by the retention inventory. + pub fn retained_ranges(&self) -> &[ProviderRetainedRange] { + &self.retained_ranges + } + + pub(super) fn validate_wire(&self) -> Result<(), IdentityError> { + let source_tree_size = + usize::try_from(self.source_tree_size).map_err(|_| IdentityError::LimitExceeded { + resource: "provider compaction source tree size", + actual: usize::MAX, + maximum: MAX_MERKLE_LOG_LEAVES, + })?; + if self.format_version != 1 { + return Err(IdentityError::UnsupportedVersion { + version: self.format_version, + }); + } + if source_tree_size > MAX_MERKLE_LOG_LEAVES { + return Err(IdentityError::limit( + "provider compaction source tree size", + source_tree_size, + MAX_MERKLE_LOG_LEAVES, + )); + } + let digests = [ + self.source_tree_root, + self.archive_commitment, + self.generation_commitment, + self.audit_commitment, + self.audit_artifact_commitment, + self.inventory_commitment, + self.retained_evidence_commitment, + ]; + if digests + .iter() + .any(|digest| digest.algorithm() != HashAlgorithm::Blake3_256) + || self.archive_commitment + != provider_recovery_commitment( + self.generation_commitment, + self.audit_commitment, + self.audit_artifact_commitment, + )? + { + return Err(IdentityError::InvalidProof); + } + let mut previous_end = 0_u64; + for range in &self.retained_ranges { + if range.start >= range.end_exclusive + || range.end_exclusive > self.source_tree_size + || range.start < previous_end + { + return Err(IdentityError::InvalidRelationship { + resource: "provider compaction retained ranges", + }); + } + previous_end = range.end_exclusive; + } + Ok(()) + } + + pub(super) fn retained_leaf_indices(&self) -> Result, IdentityError> { + let mut indices = Vec::new(); + for range in &self.retained_ranges { + let length = range + .end_exclusive + .checked_sub(range.start) + .ok_or(IdentityError::StorageCorruption)?; + let length = usize::try_from(length).map_err(|_| IdentityError::StorageCorruption)?; + if indices.len().saturating_add(length) > MAX_MERKLE_LOG_LEAVES { + return Err(IdentityError::StorageCorruption); + } + indices.extend(range.start..range.end_exclusive); + } + Ok(indices) + } + + /// Reverify the source, exact mirror, and inventory against this manifest. + pub fn verify( + &self, + source: &ProviderRecoveryExport, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, + ) -> Result<(), IdentityError> { + let expected = build_manifest(source, mirror, inventory)?; + if &expected != self { + return Err(IdentityError::InvalidProof); + } + Ok(()) + } + + pub(super) fn validate_generation( + &self, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + leaf_hashes: &[Digest], + ) -> Result<(), IdentityError> { + let source_len = + usize::try_from(self.source_tree_size).map_err(|_| IdentityError::StorageCorruption)?; + if source_len > leaf_hashes.len() { + return Err(IdentityError::StorageCorruption); + } + let source_root = crate::merkle::AppendOnlyMerkleLog::from_leaf_hashes( + leaf_hashes[..source_len].to_vec(), + )? + .root()?; + if self.format_version != 1 + || self.provider_id != provider_id + || self.log_id != log_id + || self.key_version != key_version + || self.source_tree_root != source_root + || self.archive_commitment + != provider_recovery_commitment( + self.generation_commitment, + self.audit_commitment, + self.audit_artifact_commitment, + )? + || self.archive_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.generation_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.audit_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.audit_artifact_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.inventory_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.retained_evidence_commitment.algorithm() != HashAlgorithm::Blake3_256 + { + return Err(IdentityError::StorageCorruption); + } + let mut previous_end = 0; + for range in &self.retained_ranges { + if range.start >= range.end_exclusive + || range.end_exclusive > self.source_tree_size + || range.start < previous_end + { + return Err(IdentityError::StorageCorruption); + } + previous_end = range.end_exclusive; + } + Ok(()) + } + + pub(super) fn validate_sealed_evidence( + &self, + inventory: &ProviderRetentionInventory, + retained_evidence_commitment: Digest, + ) -> Result<(), IdentityError> { + if self.inventory_commitment != inventory_commitment(inventory)? + || self.audit_artifact_commitment + != provider_audit_artifact_commitment(inventory.audit_artifacts())? + || self.retained_evidence_commitment != retained_evidence_commitment + { + return Err(IdentityError::StorageCorruption); + } + if self.retained_ranges != retained_ranges(inventory)? { + return Err(IdentityError::StorageCorruption); + } + Ok(()) + } +} + +impl ProviderCompactionManifestWire { + fn from_manifest(manifest: &ProviderCompactionManifest) -> Result { + manifest.validate_wire()?; + Ok(Self { + format_version: manifest.format_version, + provider_id: manifest.provider_id, + log_id: manifest.log_id, + key_version: manifest.key_version, + source_tree_size: manifest.source_tree_size, + source_tree_root: manifest.source_tree_root, + archive_commitment: manifest.archive_commitment, + generation_commitment: manifest.generation_commitment, + audit_commitment: manifest.audit_commitment, + audit_artifact_commitment: manifest.audit_artifact_commitment, + inventory_commitment: manifest.inventory_commitment, + retained_evidence_commitment: manifest.retained_evidence_commitment, + retained_ranges: BoundedVec::new( + "provider compaction retained ranges", + manifest.retained_ranges.clone(), + )?, + }) + } + + fn into_manifest(self) -> Result { + let manifest = ProviderCompactionManifest { + format_version: self.format_version, + provider_id: self.provider_id, + log_id: self.log_id, + key_version: self.key_version, + source_tree_size: self.source_tree_size, + source_tree_root: self.source_tree_root, + archive_commitment: self.archive_commitment, + generation_commitment: self.generation_commitment, + audit_commitment: self.audit_commitment, + audit_artifact_commitment: self.audit_artifact_commitment, + inventory_commitment: self.inventory_commitment, + retained_evidence_commitment: self.retained_evidence_commitment, + retained_ranges: self.retained_ranges.into_vec(), + }; + manifest.validate_wire()?; + Ok(manifest) + } +} + +impl CanonicalCodec for ProviderCompactionManifest { + const RESOURCE: &'static str = "provider compaction manifest bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PROVIDER_COMPACTION_MANIFEST_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&ProviderCompactionManifestWire::from_manifest(self)?) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire::(bytes)?.into_manifest() + } +} + +/// Opaque proof that a compaction source, exact full mirror, and inventory all verified. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCompactionAuthorization { + manifest: ProviderCompactionManifest, +} + +impl ProviderCompactionAuthorization { + /// Verified manifest that must be durably recorded before release. + pub const fn manifest(&self) -> &ProviderCompactionManifest { + &self.manifest + } +} + +/// Verify an exact full mirror and retention inventory before authorizing compaction. +pub fn verify_provider_compaction( + source: &ProviderRecoveryExport, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, +) -> Result { + Ok(ProviderCompactionAuthorization { + manifest: build_manifest(source, mirror, inventory)?, + }) +} + +/// Derive the semantic minimum leaf/reason inventory from authenticated generation state. +/// +/// The unique current checkpoint tip remains locally queryable; a fork retains every known branch. +/// Additional destructive/migration/recovery/rotation classes are derived from verified checkpoint +/// events, while complete ancestry remains in the exact recovery archive. Callers may retain more, +/// but cannot omit or relabel these mandatory items. +pub fn derive_provider_retention_inventory( + source: &ProviderRecoveryExport, +) -> Result { + source.validate()?; + let generation = source.generation(); + let source_store = MemoryProviderStore::restore_generation(generation.clone())?; + let snapshot = source_store.snapshot()?; + let checkpoint_index = + rebuild_checkpoint_index(&generation.entries, &generation.checkpoint_bundles)?; + let mut required_lineage = std::collections::BTreeSet::new(); + for index in &checkpoint_index { + if index.forked { + for bundle in &generation.checkpoint_bundles { + let checkpoint = bundle.verified_checkpoint(); + if checkpoint.checkpoint().body().account_id() == index.account_id { + required_lineage.insert(checkpoint.checkpoint_id()); + } + } + } else if let Some(checkpoint_id) = index.current_checkpoint_id { + // The current tip remains locally queryable. Its older ancestry is recoverable from + // the exact verified archive and deliberately releasable after sealing. + required_lineage.insert(checkpoint_id); + } + } + let mut items = Vec::new(); + for (index, entry) in generation.entries.iter().enumerate() { + let leaf_index = u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider mandatory retention leaf index", + })?; + match entry.subject() { + ProviderLogSubject::EventIntent(_) => { + items.push(ProviderRetentionItem::new( + leaf_index, + ProviderRetentionClass::Recovery, + )?); + } + ProviderLogSubject::Checkpoint(checkpoint_id) => { + if required_lineage.contains(&checkpoint_id) { + items.push(ProviderRetentionItem::new( + leaf_index, + ProviderRetentionClass::CheckpointLineage, + )?); + } + if checkpoint_index + .iter() + .any(|retained| retained.account_id == entry.account_id() && retained.forked) + { + items.push(ProviderRetentionItem::new( + leaf_index, + ProviderRetentionClass::UnresolvedFork, + )?); + } + let bundle = generation + .checkpoint_bundles + .iter() + .find(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == entry.account_id() + && checkpoint.checkpoint_id() == checkpoint_id + }) + .ok_or(IdentityError::StorageCorruption)?; + for event in bundle.events() { + add_operation_retention_items( + &mut items, + leaf_index, + event.body().operation().kind(), + )?; + } + } + } + } + items.sort_unstable(); + items.dedup(); + ProviderRetentionInventory::with_audit_artifacts( + snapshot.tree_size(), + items, + source.artifacts().to_vec(), + ) +} + +fn build_manifest( + source: &ProviderRecoveryExport, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, +) -> Result { + source.validate()?; + mirror.validate()?; + let source_store = MemoryProviderStore::restore_generation(source.generation().clone())?; + let mirror_store = MemoryProviderStore::restore_generation(mirror.generation().clone())?; + let source_snapshot = source_store.snapshot()?; + if source != mirror || source_snapshot != mirror_store.snapshot()? { + return Err(IdentityError::InvalidProof); + } + if inventory.tree_size != source_snapshot.tree_size() { + return Err(IdentityError::InvalidRelationship { + resource: "provider compaction inventory tree size", + }); + } + let mandatory = derive_provider_retention_inventory(source)?; + if mandatory + .items + .iter() + .any(|required| !inventory.items.contains(required)) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider compaction mandatory retention inventory", + }); + } + if inventory.audit_artifacts != mandatory.audit_artifacts { + return Err(IdentityError::InvalidRelationship { + resource: "provider compaction mandatory audit artifacts", + }); + } + for artifact in &inventory.audit_artifacts { + artifact.verify(source.generation().provider(), source.generation().log_id())?; + } + let retained_evidence_commitment = + retained_provider_evidence_commitment(source.generation(), inventory)?; + Ok(ProviderCompactionManifest { + format_version: 1, + provider_id: source.generation().provider.id()?, + log_id: source.generation().log_id, + key_version: source.generation().key_version, + source_tree_size: source_snapshot.tree_size(), + source_tree_root: source_snapshot.tree_root(), + archive_commitment: mirror.recovery_commitment(), + generation_commitment: mirror.generation_commitment(), + audit_commitment: mirror.audit_commitment(), + audit_artifact_commitment: mirror.artifact_commitment(), + inventory_commitment: inventory_commitment(inventory)?, + retained_evidence_commitment, + retained_ranges: retained_ranges(inventory)?, + }) +} + +fn add_operation_retention_items( + items: &mut Vec, + leaf_index: u64, + operation: OperationKind, +) -> Result<(), IdentityError> { + let mut retain = |class| { + items.push(ProviderRetentionItem::new(leaf_index, class)?); + Ok::<(), IdentityError>(()) + }; + match operation { + OperationKind::RemoveController => retain(ProviderRetentionClass::ControllerTombstone)?, + OperationKind::RevokeDevice | OperationKind::RotateDeviceKeys => { + retain(ProviderRetentionClass::DeviceTombstone)?; + } + OperationKind::ResolveFork => { + retain(ProviderRetentionClass::UnresolvedFork)?; + retain(ProviderRetentionClass::ControllerTombstone)?; + retain(ProviderRetentionClass::DeviceTombstone)?; + } + OperationKind::BeginCryptoMigration + | OperationKind::ActivateCryptoMigration + | OperationKind::RetireCryptoSuite + | OperationKind::UpgradeProtocol => retain(ProviderRetentionClass::CryptoMigration)?, + OperationKind::ChangeRecoveryPolicy + | OperationKind::BeginRecovery + | OperationKind::VetoRecovery + | OperationKind::CancelRecovery => retain(ProviderRetentionClass::Recovery)?, + OperationKind::FinalizeRecovery => { + retain(ProviderRetentionClass::Recovery)?; + retain(ProviderRetentionClass::ControllerTombstone)?; + retain(ProviderRetentionClass::DeviceTombstone)?; + } + OperationKind::ChangeProviderPolicy => retain(ProviderRetentionClass::ProviderRotation)?, + OperationKind::RetireAccount => { + retain(ProviderRetentionClass::ControllerTombstone)?; + retain(ProviderRetentionClass::DeviceTombstone)?; + } + OperationKind::AuthorizeDevice + | OperationKind::UpdateDeviceAuthorization + | OperationKind::UpdateDeviceMetadata + | OperationKind::SuspendDevice + | OperationKind::ReinstateDevice + | OperationKind::AddController + | OperationKind::ChangeControlPolicy => {} + } + Ok(()) +} + +fn retained_ranges( + inventory: &ProviderRetentionInventory, +) -> Result, IdentityError> { + let mut indices = inventory + .items + .iter() + .map(|item| item.leaf_index) + .collect::>(); + indices.sort_unstable(); + indices.dedup(); + let mut ranges = Vec::::new(); + for index in indices { + match ranges.last_mut() { + Some(range) if range.end_exclusive == index => { + range.end_exclusive = + index + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider retained range", + })?; + } + _ => ranges.push(ProviderRetainedRange { + start: index, + end_exclusive: index + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider retained range", + })?, + }), + } + } + Ok(ranges) +} + +pub(super) fn provider_generation_export_commitment( + export: &ProviderGenerationExport, +) -> Result { + let checkpoint_bundles = export + .checkpoint_bundles + .iter() + .map(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + ProviderCheckpointBundleCommitmentWire { + genesis: bundle.genesis(), + prior_checkpoint_id: bundle.prior_checkpoint_id(), + events: bundle.events(), + checkpoint: checkpoint.checkpoint(), + transition_event: checkpoint.transition_event(), + } + }) + .collect(); + provider_commitment( + PROVIDER_GENERATION_EXPORT_COMMITMENT_DOMAIN, + &ProviderGenerationExportCommitmentWire { + format_version: 1, + provider: &export.provider, + log_id: export.log_id, + key_version: export.key_version, + entries: &export.entries, + leaf_hashes: &export.leaf_hashes, + latest_head: export.latest_head.as_ref(), + receipts: &export.receipts, + checkpoint_bundles, + // A newly built manifest commits the exact pre-manifest export, avoiding + // self-reference. Later exports bind every already-durable manifest. + compaction_manifests: &export.compaction_manifests, + }, + ) +} + +fn inventory_commitment(inventory: &ProviderRetentionInventory) -> Result { + provider_commitment( + PROVIDER_RETENTION_INVENTORY_COMMITMENT_DOMAIN, + &ProviderRetentionInventoryCommitmentWire { + format_version: 1, + tree_size: inventory.tree_size, + items: inventory + .items + .iter() + .map(|item| ProviderRetentionItemCommitmentWire { + leaf_index: item.leaf_index, + class_code: item.class.code(), + }) + .collect(), + audit_artifact_commitments: inventory + .audit_artifacts + .iter() + .map(ProviderAuditArtifact::commitment) + .collect::, _>>()?, + }, + ) +} + +#[cfg(test)] +mod tests { + use krikos_base::SecretKey; + + use super::*; + use crate::{ + AccountGenesis, AuthorizedEvent, CanonicalWire, CheckpointId, Extensions, InclusionReceipt, + ProtocolSignature, ProviderAuditArtifactKind, ProviderCheckpointBundle, ProviderHeadBody, + ProviderLogEntryBody, SignedCheckpoint, SignedProviderHead, SigningPublicKey, Timestamp, + }; + + #[derive(serde::Serialize)] + struct AuditArtifactCommitmentMirror<'a> { + format_version: u16, + sequence: u64, + kind_code: u16, + accepted_head: &'a SignedProviderHead, + observed_head: &'a SignedProviderHead, + } + + #[derive(serde::Serialize)] + struct CheckpointBundleCommitmentMirror<'a> { + genesis: Option<&'a AccountGenesis>, + prior_checkpoint_id: Option, + events: &'a [AuthorizedEvent], + checkpoint: &'a SignedCheckpoint, + transition_event: Option<&'a AuthorizedEvent>, + } + + #[derive(serde::Serialize)] + struct GenerationExportCommitmentMirror<'a> { + format_version: u16, + provider: &'a crate::ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + entries: &'a [ProviderLogEntryBody], + leaf_hashes: &'a [Digest], + latest_head: Option<&'a SignedProviderHead>, + receipts: &'a [InclusionReceipt], + checkpoint_bundles: Vec>, + compaction_manifests: &'a [ProviderCompactionManifest], + } + + #[derive(serde::Serialize)] + struct RetentionItemCommitmentMirror { + leaf_index: u64, + class_code: u16, + } + + #[derive(serde::Serialize)] + struct RetentionInventoryCommitmentMirror<'a> { + format_version: u16, + tree_size: u64, + items: Vec, + audit_artifact_commitments: &'a [Digest], + } + + fn raw_commitment(domain: &[u8], value: &T) -> Digest { + let bytes = postcard::to_stdvec(value).unwrap(); + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(&bytes); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) + } + + fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() + } + + fn unsigned_head( + provider: &ProviderDescriptor, + log_id: ProviderLogId, + root_fill: u8, + observed_at: u64, + ) -> SignedProviderHead { + let body = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + 1, + Digest::new(HashAlgorithm::Blake3_256, [root_fill; 32]), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + SignedProviderHead::new(body, ProtocolSignature::ed25519([root_fill; 64])) + } + + #[test] + fn provider_compaction_roots_use_versioned_canonical_preimages() { + let signer = SecretKey::from_bytes(&[0x41; 32]); + let provider = crate::ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x42); + let export = ProviderGenerationExport { + provider: provider.clone(), + log_id, + key_version: ProviderKeyVersion::GENESIS, + entries: Vec::new(), + leaf_hashes: Vec::new(), + latest_head: None, + receipts: Vec::new(), + checkpoint_bundles: Vec::::new(), + compaction_manifests: Vec::new(), + }; + let expected_generation = raw_commitment( + b"KRIKOS-ID/provider-generation-export/v1", + &GenerationExportCommitmentMirror { + format_version: 1, + provider: &export.provider, + log_id: export.log_id, + key_version: export.key_version, + entries: &export.entries, + leaf_hashes: &export.leaf_hashes, + latest_head: export.latest_head.as_ref(), + receipts: &export.receipts, + checkpoint_bundles: Vec::new(), + compaction_manifests: &export.compaction_manifests, + }, + ); + assert_eq!( + provider_generation_export_commitment(&export).unwrap(), + expected_generation + ); + + let artifact = ProviderAuditArtifact::new( + 1, + ProviderAuditArtifactKind::Equivocation, + unsigned_head(&provider, log_id, 0x43, 100), + unsigned_head(&provider, log_id, 0x44, 101), + ) + .unwrap(); + let inventory = ProviderRetentionInventory::with_audit_artifacts( + 1, + vec![ProviderRetentionItem::new(0, ProviderRetentionClass::ProviderRotation).unwrap()], + vec![artifact.clone()], + ) + .unwrap(); + let expected_artifact = raw_commitment( + b"KRIKOS-ID/provider-audit-artifact/v1", + &AuditArtifactCommitmentMirror { + format_version: 1, + sequence: artifact.sequence(), + kind_code: 2, + accepted_head: artifact.accepted_head(), + observed_head: artifact.observed_head(), + }, + ); + let expected_inventory = raw_commitment( + b"KRIKOS-ID/provider-retention-inventory/v1", + &RetentionInventoryCommitmentMirror { + format_version: 1, + tree_size: inventory.tree_size(), + items: vec![RetentionItemCommitmentMirror { + leaf_index: 0, + class_code: 7, + }], + audit_artifact_commitments: &[expected_artifact], + }, + ); + assert_eq!( + inventory_commitment(&inventory).unwrap(), + expected_inventory + ); + } +} diff --git a/protocols/krikos-identity/src/provider/interchange.rs b/protocols/krikos-identity/src/provider/interchange.rs new file mode 100644 index 00000000000..f30d56e53d6 --- /dev/null +++ b/protocols/krikos-identity/src/provider/interchange.rs @@ -0,0 +1,2356 @@ +//! Bounded, versioned streaming interchange for complete provider recovery archives. + +#[cfg(test)] +use std::cell::Cell; +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{ + MAX_PROVIDER_COMPACTION_MANIFESTS, MemoryProviderStore, ProviderCheckpointBundleWire, + ProviderGenerationExport, ProviderGenerationSnapshot, ProviderRecoveryExport, + provider_audit_artifact_commitment, +}; +use crate::{ + CanonicalWire, Digest, HashAlgorithm, IdentityError, InclusionReceipt, + ProviderCompactionManifest, ProviderDescriptor, ProviderEquivocationEvidence, ProviderId, + ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, SignedProviderHead, + audit::{ + MAX_PROVIDER_AUDIT_RECORDS, ProviderAuditRecord, ProviderAuditRecordWire, + ProviderAuditSnapshot, provider_audit_snapshot_from_wire_records, + }, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_HISTORY_PAGE_EVENTS, MAX_MERKLE_LOG_LEAVES}, + schema::{BoundedBytes, BoundedVec}, +}; + +/// Maximum decoded items carried by one public provider interchange chunk. +pub const MAX_PROVIDER_EXPORT_CHUNK_ITEMS: usize = MAX_HISTORY_PAGE_EVENTS; +/// Maximum canonical bytes accepted by any public provider interchange chunk decoder. +pub const MAX_PROVIDER_EXPORT_CHUNK_BYTES: usize = 4 * 1024 * 1024; +/// Maximum aggregate canonical item bytes retained for one portable generation. +pub const MAX_PROVIDER_PORTABLE_GENERATION_BYTES: usize = 512 * 1024 * 1024; +/// Maximum aggregate canonical audit-record bytes retained for one portable journal. +pub const MAX_PROVIDER_PORTABLE_AUDIT_BYTES: usize = 256 * 1024 * 1024; + +#[cfg(test)] +thread_local! { + static PORTABLE_ITEM_ENCODING_COUNT: Cell = const { Cell::new(0) }; + static PORTABLE_AUDIT_RECORD_ENCODING_COUNT: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +pub(super) fn reset_portable_item_encoding_count() { + PORTABLE_ITEM_ENCODING_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(super) fn portable_item_encoding_count() -> usize { + PORTABLE_ITEM_ENCODING_COUNT.with(Cell::get) +} + +fn record_portable_item_encoding() { + #[cfg(test)] + PORTABLE_ITEM_ENCODING_COUNT.with(|count| count.set(count.get().saturating_add(1))); +} + +#[cfg(test)] +pub(crate) fn reset_portable_audit_record_encoding_count() { + PORTABLE_AUDIT_RECORD_ENCODING_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn portable_audit_record_encoding_count() -> usize { + PORTABLE_AUDIT_RECORD_ENCODING_COUNT.with(Cell::get) +} + +fn record_portable_audit_record_encoding() { + #[cfg(test)] + PORTABLE_AUDIT_RECORD_ENCODING_COUNT.with(|count| count.set(count.get().saturating_add(1))); +} + +/// Maximum canonical bytes carried by one unsplit provider interchange item. +pub const MAX_PROVIDER_EXPORT_ITEM_BYTES: usize = MAX_PROVIDER_EXPORT_CHUNK_BYTES - 4 * 1024; +const MAX_PROVIDER_EXPORT_CHUNK_PAYLOAD_BYTES: usize = MAX_PROVIDER_EXPORT_CHUNK_BYTES - 2 * 1024; +const MAX_PROVIDER_EXPORT_MANIFEST_BYTES: usize = 64 * 1024; +const MAX_PROVIDER_RECOVERY_MANIFEST_BYTES: usize = 128 * 1024; +const MAX_PROVIDER_GENERATION_COMPONENTS: usize = 5; +const MAX_PROVIDER_EXPORT_CHUNKS: usize = 65_536; + +const GENERATION_CHUNK_DOMAIN: &[u8] = b"KRIKOS-ID/provider-generation-chunk/v1"; +const GENERATION_CHUNK_LIST_DOMAIN: &[u8] = b"KRIKOS-ID/provider-generation-chunk-list/v1"; +const GENERATION_MANIFEST_DOMAIN: &[u8] = b"KRIKOS-ID/provider-generation-manifest/v1"; +const AUDIT_CHUNK_DOMAIN: &[u8] = b"KRIKOS-ID/provider-audit-chunk/v1"; +const AUDIT_CHUNK_LIST_DOMAIN: &[u8] = b"KRIKOS-ID/provider-audit-chunk-list/v1"; +const AUDIT_MANIFEST_DOMAIN: &[u8] = b"KRIKOS-ID/provider-audit-manifest/v1"; +const RECOVERY_MANIFEST_DOMAIN: &[u8] = b"KRIKOS-ID/provider-recovery-manifest/v1"; + +/// Closed component registry for generation export chunks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProviderExportComponent { + /// Canonical provider log entry bodies. + Entries, + /// Domain-separated leaf hashes parallel to the entries. + LeafHashes, + /// Inclusion receipts parallel to the entries. + Receipts, + /// Complete retained checkpoint authority bundles. + CheckpointBundles, + /// Verified compaction manifests recorded on the generation. + CompactionManifests, +} + +impl ProviderExportComponent { + /// Stable unsigned wire codepoint. + pub const fn code(self) -> u16 { + match self { + Self::Entries => 1, + Self::LeafHashes => 2, + Self::Receipts => 3, + Self::CheckpointBundles => 4, + Self::CompactionManifests => 5, + } + } + + fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::Entries), + 2 => Ok(Self::LeafHashes), + 3 => Ok(Self::Receipts), + 4 => Ok(Self::CheckpointBundles), + 5 => Ok(Self::CompactionManifests), + _ => Err(IdentityError::UnsupportedCodepoint { + registry: "provider export component", + code, + }), + } + } + + const fn ordered() -> [Self; MAX_PROVIDER_GENERATION_COMPONENTS] { + [ + Self::Entries, + Self::LeafHashes, + Self::Receipts, + Self::CheckpointBundles, + Self::CompactionManifests, + ] + } +} + +impl CanonicalCodec for ProviderExportComponent { + const RESOURCE: &'static str = "provider export component bytes"; + const MAX_ENCODED_BYTES: usize = 8; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&(1_u16, self.code())) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let (format_version, code): (u16, u16) = decode_wire(bytes)?; + require_version(format_version)?; + Self::from_code(code) + } +} + +/// Exact count/byte/list commitment for one ordered generation component stream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderExportComponentDescriptor { + format_version: u16, + component_code: u16, + item_count: u64, + chunk_count: u32, + total_payload_bytes: u64, + chunk_list_commitment: Digest, +} + +impl ProviderExportComponentDescriptor { + /// Component described by this record. + pub fn component(&self) -> Result { + ProviderExportComponent::from_code(self.component_code) + } + + /// Exact number of canonical items in the stream. + pub const fn item_count(&self) -> u64 { + self.item_count + } + + /// Exact number of bounded chunks in the stream. + pub const fn chunk_count(&self) -> u32 { + self.chunk_count + } + + /// Sum of the exact canonical item byte lengths in the stream. + pub const fn total_payload_bytes(&self) -> u64 { + self.total_payload_bytes + } + + /// Ordered-list commitment over every chunk commitment. + pub const fn chunk_list_commitment(&self) -> Digest { + self.chunk_list_commitment + } + + fn validate(&self) -> Result<(), IdentityError> { + require_version(self.format_version)?; + let component = self.component()?; + let item_limit = component_item_limit(component); + let item_count = + usize::try_from(self.item_count).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider export descriptor item count", + })?; + let chunk_count = + usize::try_from(self.chunk_count).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider export descriptor chunk count", + })?; + let minimum_chunk_count = item_count.div_ceil(MAX_PROVIDER_EXPORT_CHUNK_ITEMS); + if item_count > item_limit { + return Err(IdentityError::limit( + "provider export descriptor items", + item_count, + item_limit, + )); + } + if chunk_count > MAX_PROVIDER_EXPORT_CHUNKS + || (item_count == 0) != (chunk_count == 0) + || chunk_count < minimum_chunk_count + || chunk_count > item_count + { + return Err(IdentityError::InvalidRelationship { + resource: "provider export descriptor chunk count", + }); + } + if item_count == 0 { + if self.total_payload_bytes != 0 + || self.chunk_list_commitment + != ordered_chunk_list_commitment( + GENERATION_CHUNK_LIST_DOMAIN, + component.code(), + &[], + )? + { + return Err(IdentityError::InvalidRelationship { + resource: "empty provider export component descriptor", + }); + } + } else if self.total_payload_bytes == 0 { + return Err(IdentityError::InvalidRelationship { + resource: "provider export component payload bytes", + }); + } + if self.total_payload_bytes + > u64::try_from(MAX_PROVIDER_PORTABLE_GENERATION_BYTES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "portable generation byte limit", + } + })? + || self.chunk_list_commitment.algorithm() != HashAlgorithm::Blake3_256 + { + return Err(IdentityError::InvalidRelationship { + resource: "provider export descriptor commitment", + }); + } + Ok(()) + } +} + +impl CanonicalCodec for ProviderExportComponentDescriptor { + const RESOURCE: &'static str = "provider export component descriptor bytes"; + const MAX_ENCODED_BYTES: usize = 128; + + fn encode_canonical(&self) -> Result, IdentityError> { + self.validate()?; + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let value: Self = decode_wire(bytes)?; + value.validate()?; + Ok(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderCheckpointBundleItemWire { + format_version: u16, + bundle: ProviderCheckpointBundleWire, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderAuditRecordItemWire { + format_version: u16, + record: ProviderAuditRecordWire, +} + +#[derive(Serialize)] +struct ProviderGenerationChunkCommitmentWire<'a> { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + generation_commitment: Digest, + component_code: u16, + ordinal: u32, + start_index: u64, + end_index: u64, + item_payload_bytes: u64, + payload: &'a [u8], +} + +#[derive(Serialize)] +struct ProviderAuditChunkCommitmentWire<'a> { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + audit_commitment: Digest, + ordinal: u32, + start_sequence: u64, + end_sequence: u64, + item_payload_bytes: u64, + payload: &'a [u8], +} + +#[derive(Serialize)] +struct ProviderChunkListCommitmentWire<'a> { + format_version: u16, + component_code: u16, + chunk_count: u32, + commitments: &'a [Digest], +} + +type ChunkItems = + BoundedVec, MAX_PROVIDER_EXPORT_CHUNK_ITEMS>; + +/// One independently bounded ordered slice of a generation component stream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderGenerationExportChunk { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + generation_commitment: Digest, + component_code: u16, + ordinal: u32, + start_index: u64, + end_index: u64, + item_payload_bytes: u64, + payload: BoundedBytes, +} + +/// One independently bounded ordered slice of an audit journal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderAuditExportChunk { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + audit_commitment: Digest, + ordinal: u32, + start_sequence: u64, + end_sequence: u64, + item_payload_bytes: u64, + payload: BoundedBytes, +} + +impl ProviderGenerationExportChunk { + /// Provider identity routing this chunk. + pub const fn provider_id(&self) -> ProviderId { + self.provider_id + } + + /// Provider log generation routing this chunk. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Provider signing-key generation routing this chunk. + pub const fn key_version(&self) -> ProviderKeyVersion { + self.key_version + } + + /// Authoritative aggregate commitment binding this chunk. + pub const fn generation_commitment(&self) -> Digest { + self.generation_commitment + } + + /// Component carried by this chunk. + pub fn component(&self) -> Result { + ProviderExportComponent::from_code(self.component_code) + } + + /// Zero-based ordinal inside the component stream. + pub const fn ordinal(&self) -> u32 { + self.ordinal + } + + /// Inclusive item offset inside the complete component stream. + pub const fn start_index(&self) -> u64 { + self.start_index + } + + /// Exclusive item offset inside the complete component stream. + pub const fn end_index(&self) -> u64 { + self.end_index + } + + /// Sum of the canonical item byte lengths in this chunk. + pub const fn item_payload_bytes(&self) -> u64 { + self.item_payload_bytes + } + + /// Domain-separated commitment over the exact route, range, and payload fields. + pub fn commitment(&self) -> Result { + self.validate()?; + domain_commitment( + GENERATION_CHUNK_DOMAIN, + &ProviderGenerationChunkCommitmentWire { + format_version: 1, + provider_id: self.provider_id, + log_id: self.log_id, + key_version: self.key_version, + generation_commitment: self.generation_commitment, + component_code: self.component_code, + ordinal: self.ordinal, + start_index: self.start_index, + end_index: self.end_index, + item_payload_bytes: self.item_payload_bytes, + payload: self.payload.as_slice(), + }, + ) + } + + fn item_bytes(&self) -> Result>, IdentityError> { + decode_chunk_items(self.payload.as_slice()) + } + + fn validate(&self) -> Result<(), IdentityError> { + require_version(self.format_version)?; + let component = self.component()?; + if self.key_version != ProviderKeyVersion::GENESIS + || self.generation_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.start_index >= self.end_index + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation export chunk header", + }); + } + let items = self.item_bytes()?; + let expected_count = self.end_index.checked_sub(self.start_index).ok_or( + IdentityError::ArithmeticOverflow { + resource: "provider generation export chunk range", + }, + )?; + if u64::try_from(items.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider generation export chunk items", + })? != expected_count + || canonical_item_bytes(&items)? != self.item_payload_bytes + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation export chunk payload accounting", + }); + } + for item in &items { + validate_generation_item(component, item)?; + } + Ok(()) + } +} + +impl CanonicalCodec for ProviderGenerationExportChunk { + const RESOURCE: &'static str = "provider generation export chunk bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PROVIDER_EXPORT_CHUNK_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + self.validate()?; + let bytes = encode_wire(self)?; + check_chunk_size(bytes) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PROVIDER_EXPORT_CHUNK_BYTES { + return Err(IdentityError::limit( + Self::RESOURCE, + bytes.len(), + MAX_PROVIDER_EXPORT_CHUNK_BYTES, + )); + } + let value: Self = decode_wire(bytes)?; + value.validate()?; + Ok(value) + } +} + +impl ProviderAuditExportChunk { + /// Provider identity routing this chunk. + pub const fn provider_id(&self) -> ProviderId { + self.provider_id + } + + /// Provider log generation routing this chunk. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Authoritative audit-journal commitment binding this chunk. + pub const fn audit_commitment(&self) -> Digest { + self.audit_commitment + } + + /// Zero-based ordinal inside the audit-record stream. + pub const fn ordinal(&self) -> u32 { + self.ordinal + } + + /// Inclusive one-based journal sequence. + pub const fn start_sequence(&self) -> u64 { + self.start_sequence + } + + /// Exclusive one-based journal sequence bound. + pub const fn end_sequence(&self) -> u64 { + self.end_sequence + } + + /// Sum of the canonical audit-record item byte lengths in this chunk. + pub const fn item_payload_bytes(&self) -> u64 { + self.item_payload_bytes + } + + /// Domain-separated commitment over the exact journal route, range, and payload fields. + pub fn commitment(&self) -> Result { + self.validate()?; + domain_commitment( + AUDIT_CHUNK_DOMAIN, + &ProviderAuditChunkCommitmentWire { + format_version: 1, + provider_id: self.provider_id, + log_id: self.log_id, + audit_commitment: self.audit_commitment, + ordinal: self.ordinal, + start_sequence: self.start_sequence, + end_sequence: self.end_sequence, + item_payload_bytes: self.item_payload_bytes, + payload: self.payload.as_slice(), + }, + ) + } + + fn item_bytes(&self) -> Result>, IdentityError> { + decode_chunk_items(self.payload.as_slice()) + } + + fn validate(&self) -> Result<(), IdentityError> { + require_version(self.format_version)?; + if self.audit_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.start_sequence == 0 + || self.start_sequence >= self.end_sequence + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit export chunk header", + }); + } + let items = self.item_bytes()?; + let expected_count = self.end_sequence.checked_sub(self.start_sequence).ok_or( + IdentityError::ArithmeticOverflow { + resource: "provider audit export chunk range", + }, + )?; + if u64::try_from(items.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit export chunk items", + })? != expected_count + || canonical_item_bytes(&items)? != self.item_payload_bytes + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit export chunk payload accounting", + }); + } + for item in &items { + decode_audit_record_item(item)?; + } + Ok(()) + } +} + +impl CanonicalCodec for ProviderAuditExportChunk { + const RESOURCE: &'static str = "provider audit export chunk bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PROVIDER_EXPORT_CHUNK_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + self.validate()?; + let bytes = encode_wire(self)?; + check_chunk_size(bytes) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PROVIDER_EXPORT_CHUNK_BYTES { + return Err(IdentityError::limit( + Self::RESOURCE, + bytes.len(), + MAX_PROVIDER_EXPORT_CHUNK_BYTES, + )); + } + let value: Self = decode_wire(bytes)?; + value.validate()?; + Ok(value) + } +} + +/// Small authenticated index for every component chunk of one complete generation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderGenerationExportManifest { + format_version: u16, + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + tree_size: u64, + tree_root: Digest, + latest_head: Option, + generation_commitment: Digest, + total_payload_bytes: u64, + components: BoundedVec, +} + +/// Small authenticated index for every chunk of one complete audit journal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderAuditExportManifest { + format_version: u16, + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, + record_count: u64, + chunk_count: u32, + total_payload_bytes: u64, + audit_commitment: Digest, + artifact_count: u64, + artifact_commitment: Digest, + chunk_list_commitment: Digest, +} + +/// Canonical recovery entry point binding exact generation and audit interchange manifests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderRecoveryExportManifest { + format_version: u16, + generation: ProviderGenerationExportManifest, + audit: ProviderAuditExportManifest, + generation_manifest_commitment: Digest, + audit_manifest_commitment: Digest, + generation_commitment: Digest, + audit_commitment: Digest, + artifact_commitment: Digest, + recovery_commitment: Digest, +} + +impl ProviderGenerationExportManifest { + /// Provider descriptor authenticating this generation. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Exact log generation. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Exact provider signing-key generation. + pub const fn key_version(&self) -> ProviderKeyVersion { + self.key_version + } + + /// Complete Merkle tree size. + pub const fn tree_size(&self) -> u64 { + self.tree_size + } + + /// Complete Merkle tree root. + pub const fn tree_root(&self) -> Digest { + self.tree_root + } + + /// Latest authenticated provider head. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } + + /// Authoritative full-generation commitment. + pub const fn generation_commitment(&self) -> Digest { + self.generation_commitment + } + + /// Exact sum of canonical item bytes across all component streams. + pub const fn total_payload_bytes(&self) -> u64 { + self.total_payload_bytes + } + + /// Fixed component descriptors in ascending codepoint order. + pub fn components(&self) -> &[ProviderExportComponentDescriptor] { + self.components.as_slice() + } + + /// Descriptor for one exact component stream. + pub fn descriptor( + &self, + component: ProviderExportComponent, + ) -> Result<&ProviderExportComponentDescriptor, IdentityError> { + self.components + .as_slice() + .iter() + .find(|descriptor| descriptor.component_code == component.code()) + .ok_or(IdentityError::InvalidRelationship { + resource: "provider generation manifest component coverage", + }) + } + + /// Domain-separated commitment to the exact canonical manifest bytes. + pub fn commitment(&self) -> Result { + self.validate()?; + domain_commitment(GENERATION_MANIFEST_DOMAIN, self) + } + + fn validate(&self) -> Result<(), IdentityError> { + require_version(self.format_version)?; + if self.key_version != ProviderKeyVersion::GENESIS + || self.tree_size + > u64::try_from(MAX_MERKLE_LOG_LEAVES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider Merkle tree limit", + } + })? + || self.tree_root.algorithm() != HashAlgorithm::Blake3_256 + || self.generation_commitment.algorithm() != HashAlgorithm::Blake3_256 + || self.total_payload_bytes + > u64::try_from(MAX_PROVIDER_PORTABLE_GENERATION_BYTES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "portable generation byte limit", + } + })? + || self.components.len() != MAX_PROVIDER_GENERATION_COMPONENTS + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation export manifest", + }); + } + let provider_id = self.provider.id()?; + match (&self.latest_head, self.tree_size) { + (None, 0) if self.tree_root == crate::merkle::empty_merkle_root() => {} + (Some(head), size) if size != 0 => { + head.verify(&self.provider)?; + if head.body().provider_id() != provider_id + || head.body().log_id() != self.log_id + || head.body().key_version() != self.key_version + || head.body().tree_size() != self.tree_size + || head.body().tree_root() != self.tree_root + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation manifest latest head", + }); + } + } + _ => { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation manifest empty state", + }); + } + } + let mut total_payload_bytes = 0_u64; + let mut total_chunk_count = 0_usize; + for (expected, descriptor) in ProviderExportComponent::ordered() + .into_iter() + .zip(self.components.as_slice()) + { + descriptor.validate()?; + if descriptor.component()? != expected { + return Err(IdentityError::NonCanonical); + } + total_payload_bytes = total_payload_bytes + .checked_add(descriptor.total_payload_bytes) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider generation manifest total bytes", + })?; + total_chunk_count = total_chunk_count + .checked_add(usize::try_from(descriptor.chunk_count).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider generation manifest total chunks", + } + })?) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider generation manifest total chunks", + })?; + } + if total_payload_bytes != self.total_payload_bytes + || total_chunk_count > MAX_PROVIDER_EXPORT_CHUNKS + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation manifest aggregate accounting", + }); + } + let entries = self.descriptor(ProviderExportComponent::Entries)?; + let leaves = self.descriptor(ProviderExportComponent::LeafHashes)?; + let receipts = self.descriptor(ProviderExportComponent::Receipts)?; + let checkpoint_bundles = self.descriptor(ProviderExportComponent::CheckpointBundles)?; + if entries.item_count != self.tree_size + || leaves.item_count != self.tree_size + || receipts.item_count != self.tree_size + || checkpoint_bundles.item_count > self.tree_size + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation manifest parallel streams", + }); + } + Ok(()) + } +} + +impl CanonicalCodec for ProviderGenerationExportManifest { + const RESOURCE: &'static str = "provider generation export manifest bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PROVIDER_EXPORT_MANIFEST_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + self.validate()?; + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let value: Self = decode_wire(bytes)?; + value.validate()?; + Ok(value) + } +} + +impl ProviderAuditExportManifest { + /// Provider descriptor authenticating this journal. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Exact log generation audited by this journal. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Latest accepted authenticated provider head. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } + + /// First retained same-size conflicting-head evidence, if any. + pub const fn equivocation_evidence(&self) -> Option<&ProviderEquivocationEvidence> { + self.equivocation.as_ref() + } + + /// Exact number of audit records. + pub const fn record_count(&self) -> u64 { + self.record_count + } + + /// Exact number of bounded audit chunks. + pub const fn chunk_count(&self) -> u32 { + self.chunk_count + } + + /// Sum of exact canonical audit-record item bytes. + pub const fn total_payload_bytes(&self) -> u64 { + self.total_payload_bytes + } + + /// Authoritative complete audit-journal commitment. + pub const fn audit_commitment(&self) -> Digest { + self.audit_commitment + } + + /// Authoritative sorted audit-artifact commitment. + pub const fn artifact_commitment(&self) -> Digest { + self.artifact_commitment + } + + /// Exact number of derived rollback/equivocation artifacts. + pub const fn artifact_count(&self) -> u64 { + self.artifact_count + } + + /// Ordered-list commitment over every audit chunk commitment. + pub const fn chunk_list_commitment(&self) -> Digest { + self.chunk_list_commitment + } + + /// Domain-separated commitment to the exact canonical manifest bytes. + pub fn commitment(&self) -> Result { + self.validate()?; + domain_commitment(AUDIT_MANIFEST_DOMAIN, self) + } + + fn validate(&self) -> Result<(), IdentityError> { + require_version(self.format_version)?; + let record_count = + usize::try_from(self.record_count).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit manifest record count", + })?; + let chunk_count = + usize::try_from(self.chunk_count).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit manifest chunk count", + })?; + let minimum_chunk_count = record_count.div_ceil(MAX_PROVIDER_EXPORT_CHUNK_ITEMS); + if record_count > MAX_PROVIDER_AUDIT_RECORDS + || chunk_count > MAX_PROVIDER_EXPORT_CHUNKS + || (record_count == 0) != (chunk_count == 0) + || chunk_count < minimum_chunk_count + || chunk_count > record_count + || self.total_payload_bytes + > u64::try_from(MAX_PROVIDER_PORTABLE_AUDIT_BYTES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "portable audit byte limit", + } + })? + || [ + self.audit_commitment, + self.artifact_commitment, + self.chunk_list_commitment, + ] + .iter() + .any(|digest| digest.algorithm() != HashAlgorithm::Blake3_256) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit export manifest", + }); + } + if record_count == 0 { + let empty_snapshot = provider_audit_snapshot_from_wire_records( + self.provider.clone(), + self.log_id, + None, + None, + Vec::new(), + )?; + if self.latest_head.is_some() + || self.equivocation.is_some() + || self.artifact_count != 0 + || self.total_payload_bytes != 0 + || self.audit_commitment != empty_snapshot.commitment()? + || self.artifact_commitment != provider_audit_artifact_commitment(&[])? + || self.chunk_list_commitment + != ordered_chunk_list_commitment(AUDIT_CHUNK_LIST_DOMAIN, 0, &[])? + { + return Err(IdentityError::InvalidRelationship { + resource: "empty provider audit export manifest", + }); + } + } else if self.latest_head.is_none() + || self.total_payload_bytes == 0 + || self.artifact_count > self.record_count + || (self.equivocation.is_some() && self.artifact_count == 0) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit export manifest record accounting", + }); + } + let provider_id = self.provider.id()?; + if let Some(head) = &self.latest_head { + head.verify(&self.provider)?; + if head.body().provider_id() != provider_id || head.body().log_id() != self.log_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit manifest latest head", + }); + } + } + if let Some(evidence) = &self.equivocation { + evidence.verify(&self.provider)?; + if evidence.first().body().provider_id() != provider_id + || evidence.second().body().provider_id() != provider_id + || evidence.first().body().log_id() != self.log_id + || evidence.second().body().log_id() != self.log_id + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit manifest equivocation route", + }); + } + } + Ok(()) + } +} + +impl CanonicalCodec for ProviderAuditExportManifest { + const RESOURCE: &'static str = "provider audit export manifest bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PROVIDER_EXPORT_MANIFEST_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + self.validate()?; + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let value: Self = decode_wire(bytes)?; + value.validate()?; + Ok(value) + } +} + +impl ProviderRecoveryExportManifest { + /// Complete generation chunk index. + pub const fn generation(&self) -> &ProviderGenerationExportManifest { + &self.generation + } + + /// Complete audit-journal chunk index. + pub const fn audit(&self) -> &ProviderAuditExportManifest { + &self.audit + } + + /// Composite authoritative recovery commitment. + pub const fn recovery_commitment(&self) -> Digest { + self.recovery_commitment + } + + /// Commitment to the exact embedded generation manifest. + pub const fn generation_manifest_commitment(&self) -> Digest { + self.generation_manifest_commitment + } + + /// Commitment to the exact embedded audit manifest. + pub const fn audit_manifest_commitment(&self) -> Digest { + self.audit_manifest_commitment + } + + /// Authoritative generation aggregate commitment. + pub const fn generation_commitment(&self) -> Digest { + self.generation_commitment + } + + /// Authoritative audit aggregate commitment. + pub const fn audit_commitment(&self) -> Digest { + self.audit_commitment + } + + /// Authoritative derived-artifact aggregate commitment. + pub const fn artifact_commitment(&self) -> Digest { + self.artifact_commitment + } + + /// Domain-separated commitment to the exact canonical recovery manifest bytes. + pub fn commitment(&self) -> Result { + self.validate()?; + domain_commitment(RECOVERY_MANIFEST_DOMAIN, self) + } + + /// Bind two fully assembled aggregates through the authoritative recovery constructor. + pub fn finish( + &self, + generation: ProviderGenerationExport, + audit: ProviderAuditSnapshot, + ) -> Result { + self.validate()?; + if generation.provider() != self.generation.provider() + || generation.log_id() != self.generation.log_id() + || audit.provider() != self.audit.provider() + || audit.log_id() != self.audit.log_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "provider recovery manifest assembled route", + }); + } + let recovery = ProviderRecoveryExport::new(generation, audit)?; + if recovery.generation_commitment() != self.generation_commitment + || recovery.audit_commitment() != self.audit_commitment + || recovery.artifact_commitment() != self.artifact_commitment + || recovery.recovery_commitment() != self.recovery_commitment + { + return Err(IdentityError::InvalidProof); + } + Ok(recovery) + } + + fn validate(&self) -> Result<(), IdentityError> { + require_version(self.format_version)?; + self.generation.validate()?; + self.audit.validate()?; + if self.generation.provider != self.audit.provider + || self.generation.log_id != self.audit.log_id + || self.generation.latest_head.as_ref() != self.audit.latest_head.as_ref() + || self.generation_manifest_commitment != self.generation.commitment()? + || self.audit_manifest_commitment != self.audit.commitment()? + || self.generation_commitment != self.generation.generation_commitment + || self.audit_commitment != self.audit.audit_commitment + || self.artifact_commitment != self.audit.artifact_commitment + || [ + self.generation_manifest_commitment, + self.audit_manifest_commitment, + self.generation_commitment, + self.audit_commitment, + self.artifact_commitment, + self.recovery_commitment, + ] + .iter() + .any(|digest| digest.algorithm() != HashAlgorithm::Blake3_256) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider recovery export manifest", + }); + } + let expected_recovery = super::provider_recovery_commitment( + self.generation_commitment, + self.audit_commitment, + self.artifact_commitment, + )?; + if self.recovery_commitment != expected_recovery { + return Err(IdentityError::InvalidProof); + } + Ok(()) + } +} + +impl CanonicalCodec for ProviderRecoveryExportManifest { + const RESOURCE: &'static str = "provider recovery export manifest bytes"; + const MAX_ENCODED_BYTES: usize = MAX_PROVIDER_RECOVERY_MANIFEST_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + self.validate()?; + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let value: Self = decode_wire(bytes)?; + value.validate()?; + Ok(value) + } +} + +/// Bounded out-of-order assembler for one generation manifest. +#[derive(Debug)] +pub struct ProviderGenerationExportAssembler { + manifest: ProviderGenerationExportManifest, + pending: BTreeMap<(u16, u32), ProviderGenerationExportChunk>, + retained_payload_bytes: u64, +} + +/// Bounded out-of-order assembler for one audit manifest. +#[derive(Debug)] +pub struct ProviderAuditExportAssembler { + manifest: ProviderAuditExportManifest, + pending: BTreeMap, + retained_payload_bytes: u64, +} + +impl ProviderGenerationExportAssembler { + /// Start an assembler only after validating all aggregate count and byte commitments. + pub fn new(manifest: ProviderGenerationExportManifest) -> Result { + manifest.validate()?; + Ok(Self { + manifest, + pending: BTreeMap::new(), + retained_payload_bytes: 0, + }) + } + + /// Insert one chunk. Exact replay is idempotent; a conflicting ordinal fails closed. + pub fn insert(&mut self, chunk: ProviderGenerationExportChunk) -> Result { + chunk.validate()?; + if chunk.provider_id != self.manifest.provider.id()? + || chunk.log_id != self.manifest.log_id + || chunk.key_version != self.manifest.key_version + || chunk.generation_commitment != self.manifest.generation_commitment + { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation chunk manifest binding", + }); + } + let component = chunk.component()?; + let descriptor = self.manifest.descriptor(component)?; + if chunk.ordinal >= descriptor.chunk_count || chunk.end_index > descriptor.item_count { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation chunk descriptor range", + }); + } + let key = (component.code(), chunk.ordinal); + if let Some(retained) = self.pending.get(&key) { + return if retained == &chunk { + Ok(false) + } else { + Err(IdentityError::DuplicateElement { + resource: "provider generation chunk ordinal", + }) + }; + } + let next_payload_bytes = self + .retained_payload_bytes + .checked_add(chunk.item_payload_bytes) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider generation assembler retained bytes", + })?; + if next_payload_bytes > self.manifest.total_payload_bytes + || next_payload_bytes + > u64::try_from(MAX_PROVIDER_PORTABLE_GENERATION_BYTES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "portable generation byte limit", + } + })? + { + return Err(IdentityError::limit( + "provider generation assembler retained bytes", + usize::try_from(next_payload_bytes).unwrap_or(usize::MAX), + MAX_PROVIDER_PORTABLE_GENERATION_BYTES, + )); + } + self.pending.insert(key, chunk); + self.retained_payload_bytes = next_payload_bytes; + Ok(true) + } + + /// Finish only after every committed component range is present exactly once. + pub fn finish(self) -> Result { + let expected_chunks = + self.manifest + .components + .as_slice() + .iter() + .try_fold(0_usize, |total, descriptor| { + total + .checked_add(usize::try_from(descriptor.chunk_count).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider generation expected chunks", + } + })?) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider generation expected chunks", + }) + })?; + if self.pending.len() != expected_chunks + || self.retained_payload_bytes != self.manifest.total_payload_bytes + { + return Err(IdentityError::InvalidRelationship { + resource: "incomplete provider generation export", + }); + } + + let entries = decode_generation_component::( + &self.manifest, + &self.pending, + ProviderExportComponent::Entries, + )?; + let leaf_hashes = decode_generation_component::( + &self.manifest, + &self.pending, + ProviderExportComponent::LeafHashes, + )?; + let receipts = decode_generation_component::( + &self.manifest, + &self.pending, + ProviderExportComponent::Receipts, + )?; + let checkpoint_bundles = decode_checkpoint_bundle_component(&self.manifest, &self.pending)?; + let compaction_manifests = decode_generation_component::( + &self.manifest, + &self.pending, + ProviderExportComponent::CompactionManifests, + )?; + let export = ProviderGenerationExport { + provider: self.manifest.provider.clone(), + log_id: self.manifest.log_id, + key_version: self.manifest.key_version, + entries, + leaf_hashes, + latest_head: self.manifest.latest_head.clone(), + receipts, + checkpoint_bundles, + compaction_manifests, + }; + let restored = MemoryProviderStore::restore_generation(export)?; + let (rebuilt, snapshot) = restored.export_and_snapshot_from_validated_state()?; + let (manifest, _) = rebuilt.interchange_parts_validated(&snapshot)?; + if manifest != self.manifest { + return Err(IdentityError::InvalidProof); + } + Ok(rebuilt) + } +} + +impl ProviderAuditExportAssembler { + /// Start an assembler only after validating all aggregate count and byte commitments. + pub fn new(manifest: ProviderAuditExportManifest) -> Result { + manifest.validate()?; + Ok(Self { + manifest, + pending: BTreeMap::new(), + retained_payload_bytes: 0, + }) + } + + /// Insert one chunk. Exact replay is idempotent; a conflicting ordinal fails closed. + pub fn insert(&mut self, chunk: ProviderAuditExportChunk) -> Result { + chunk.validate()?; + if chunk.provider_id != self.manifest.provider.id()? + || chunk.log_id != self.manifest.log_id + || chunk.audit_commitment != self.manifest.audit_commitment + || chunk.ordinal >= self.manifest.chunk_count + || chunk.end_sequence > self.manifest.record_count.saturating_add(1) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit chunk manifest binding", + }); + } + if let Some(retained) = self.pending.get(&chunk.ordinal) { + return if retained == &chunk { + Ok(false) + } else { + Err(IdentityError::DuplicateElement { + resource: "provider audit chunk ordinal", + }) + }; + } + let next_payload_bytes = self + .retained_payload_bytes + .checked_add(chunk.item_payload_bytes) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider audit assembler retained bytes", + })?; + if next_payload_bytes > self.manifest.total_payload_bytes + || next_payload_bytes + > u64::try_from(MAX_PROVIDER_PORTABLE_AUDIT_BYTES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "portable audit byte limit", + } + })? + { + return Err(IdentityError::limit( + "provider audit assembler retained bytes", + usize::try_from(next_payload_bytes).unwrap_or(usize::MAX), + MAX_PROVIDER_PORTABLE_AUDIT_BYTES, + )); + } + self.retained_payload_bytes = next_payload_bytes; + self.pending.insert(chunk.ordinal, chunk); + Ok(true) + } + + /// Finish only after every committed journal range is present exactly once. + pub fn finish(self) -> Result { + if self.pending.len() + != usize::try_from(self.manifest.chunk_count).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit expected chunks", + } + })? + || self.retained_payload_bytes != self.manifest.total_payload_bytes + { + return Err(IdentityError::InvalidRelationship { + resource: "incomplete provider audit export", + }); + } + let mut expected_sequence = 1_u64; + let mut commitments = Vec::with_capacity(self.pending.len()); + let mut records = + Vec::with_capacity(usize::try_from(self.manifest.record_count).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit record allocation", + } + })?); + for ordinal in 0..self.manifest.chunk_count { + let chunk = self + .pending + .get(&ordinal) + .ok_or(IdentityError::InvalidRelationship { + resource: "provider audit chunk ordinal gap", + })?; + if chunk.start_sequence != expected_sequence { + return Err(IdentityError::InvalidRelationship { + resource: "provider audit chunk range gap or overlap", + }); + } + expected_sequence = chunk.end_sequence; + commitments.push(chunk.commitment()?); + records.extend( + chunk + .item_bytes()? + .into_iter() + .map(|bytes| decode_audit_record_item(&bytes)) + .collect::, _>>()?, + ); + } + if expected_sequence != self.manifest.record_count.saturating_add(1) + || ordered_chunk_list_commitment(AUDIT_CHUNK_LIST_DOMAIN, 0, &commitments)? + != self.manifest.chunk_list_commitment + { + return Err(IdentityError::InvalidProof); + } + let snapshot = provider_audit_snapshot_from_wire_records( + self.manifest.provider.clone(), + self.manifest.log_id, + self.manifest.latest_head.clone(), + self.manifest.equivocation.clone(), + records, + )?; + if snapshot.commitment_validated()? != self.manifest.audit_commitment { + return Err(IdentityError::InvalidProof); + } + let artifacts = snapshot.artifacts_validated()?; + if u64::try_from(artifacts.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit artifact count", + })? != self.manifest.artifact_count + || provider_audit_artifact_commitment(&artifacts)? != self.manifest.artifact_commitment + { + return Err(IdentityError::InvalidProof); + } + Ok(snapshot) + } +} + +impl ProviderGenerationExport { + /// Build the small canonical manifest and independently bounded component chunks. + pub fn interchange_parts( + &self, + ) -> Result< + ( + ProviderGenerationExportManifest, + Vec, + ), + IdentityError, + > { + let store = MemoryProviderStore::restore_generation(self.clone())?; + let (restored, snapshot) = store.export_and_snapshot_from_validated_state()?; + if &restored != self { + return Err(IdentityError::InvalidProof); + } + restored.interchange_parts_validated(&snapshot) + } + + fn interchange_parts_validated( + &self, + snapshot: &ProviderGenerationSnapshot, + ) -> Result< + ( + ProviderGenerationExportManifest, + Vec, + ), + IdentityError, + > { + let generation_commitment = super::compaction::provider_generation_export_commitment(self)?; + let provider_id = self.provider.id()?; + let mut all_chunks = Vec::new(); + let mut descriptors = Vec::with_capacity(MAX_PROVIDER_GENERATION_COMPONENTS); + + for component in ProviderExportComponent::ordered() { + let items = encode_generation_component_items(self, component)?; + let chunks = build_generation_chunks( + provider_id, + self.log_id, + self.key_version, + generation_commitment, + component, + items, + )?; + let item_count = chunks.iter().try_fold(0_u64, |total, chunk| { + total + .checked_add(chunk.end_index.checked_sub(chunk.start_index).ok_or( + IdentityError::ArithmeticOverflow { + resource: "provider generation component chunk range", + }, + )?) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider generation component items", + }) + })?; + let total_payload_bytes = chunks.iter().try_fold(0_u64, |total, chunk| { + total.checked_add(chunk.item_payload_bytes).ok_or( + IdentityError::ArithmeticOverflow { + resource: "provider generation component bytes", + }, + ) + })?; + let commitments = chunks + .iter() + .map(ProviderGenerationExportChunk::commitment) + .collect::, _>>()?; + descriptors.push(ProviderExportComponentDescriptor { + format_version: 1, + component_code: component.code(), + item_count, + chunk_count: u32::try_from(chunks.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider generation component chunks", + } + })?, + total_payload_bytes, + chunk_list_commitment: ordered_chunk_list_commitment( + GENERATION_CHUNK_LIST_DOMAIN, + component.code(), + &commitments, + )?, + }); + all_chunks.extend(chunks); + } + let total_payload_bytes = descriptors.iter().try_fold(0_u64, |total, descriptor| { + total.checked_add(descriptor.total_payload_bytes).ok_or( + IdentityError::ArithmeticOverflow { + resource: "provider generation manifest bytes", + }, + ) + })?; + if total_payload_bytes + > u64::try_from(MAX_PROVIDER_PORTABLE_GENERATION_BYTES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "portable generation byte limit", + } + })? + { + return Err(IdentityError::limit( + "portable provider generation bytes", + usize::try_from(total_payload_bytes).unwrap_or(usize::MAX), + MAX_PROVIDER_PORTABLE_GENERATION_BYTES, + )); + } + let manifest = ProviderGenerationExportManifest { + format_version: 1, + provider: self.provider.clone(), + log_id: self.log_id, + key_version: self.key_version, + tree_size: snapshot.tree_size(), + tree_root: snapshot.tree_root(), + latest_head: self.latest_head.clone(), + generation_commitment, + total_payload_bytes, + components: BoundedVec::new("provider generation component descriptors", descriptors)?, + }; + manifest.to_canonical_bytes()?; + Ok((manifest, all_chunks)) + } +} + +impl ProviderAuditSnapshot { + /// Build the small canonical manifest and independently bounded journal chunks. + pub fn interchange_parts( + &self, + ) -> Result<(ProviderAuditExportManifest, Vec), IdentityError> { + self.validate()?; + self.interchange_parts_validated() + } + + fn interchange_parts_validated( + &self, + ) -> Result<(ProviderAuditExportManifest, Vec), IdentityError> { + let audit_commitment = self.commitment_validated()?; + let artifacts = self.artifacts_validated()?; + let artifact_commitment = provider_audit_artifact_commitment(&artifacts)?; + let item_bytes = self + .records() + .iter() + .map(|record| { + encode_wire(&ProviderAuditRecordItemWire { + format_version: 1, + record: ProviderAuditRecordWire::from_record(record), + }) + }) + .collect::, _>>()?; + let chunks = build_audit_chunks( + self.provider().id()?, + self.log_id(), + audit_commitment, + item_bytes, + )?; + let total_payload_bytes = chunks.iter().try_fold(0_u64, |total, chunk| { + total + .checked_add(chunk.item_payload_bytes) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider audit manifest bytes", + }) + })?; + if total_payload_bytes + > u64::try_from(MAX_PROVIDER_PORTABLE_AUDIT_BYTES).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "portable audit byte limit", + } + })? + { + return Err(IdentityError::limit( + "portable provider audit bytes", + usize::try_from(total_payload_bytes).unwrap_or(usize::MAX), + MAX_PROVIDER_PORTABLE_AUDIT_BYTES, + )); + } + let commitments = chunks + .iter() + .map(ProviderAuditExportChunk::commitment) + .collect::, _>>()?; + let manifest = ProviderAuditExportManifest { + format_version: 1, + provider: self.provider().clone(), + log_id: self.log_id(), + latest_head: self.latest_head().cloned(), + equivocation: self.equivocation_evidence().cloned(), + record_count: u64::try_from(self.records().len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit manifest records", + } + })?, + chunk_count: u32::try_from(chunks.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit manifest chunks", + } + })?, + total_payload_bytes, + audit_commitment, + artifact_count: u64::try_from(artifacts.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider audit manifest artifacts", + } + })?, + artifact_commitment, + chunk_list_commitment: ordered_chunk_list_commitment( + AUDIT_CHUNK_LIST_DOMAIN, + 0, + &commitments, + )?, + }; + manifest.to_canonical_bytes()?; + Ok((manifest, chunks)) + } +} + +impl ProviderRecoveryExport { + /// Build the recovery manifest plus all independently bounded generation and audit chunks. + pub fn interchange_parts( + &self, + ) -> Result< + ( + ProviderRecoveryExportManifest, + Vec, + Vec, + ), + IdentityError, + > { + let generation_snapshot = self.validate_with_generation_snapshot()?; + let (generation, generation_chunks) = self + .generation() + .interchange_parts_validated(&generation_snapshot)?; + let (audit, audit_chunks) = self.audit().interchange_parts_validated()?; + let manifest = ProviderRecoveryExportManifest { + format_version: 1, + generation_manifest_commitment: generation.commitment()?, + audit_manifest_commitment: audit.commitment()?, + generation_commitment: self.generation_commitment(), + audit_commitment: self.audit_commitment(), + artifact_commitment: self.artifact_commitment(), + recovery_commitment: self.recovery_commitment(), + generation, + audit, + }; + manifest.to_canonical_bytes()?; + Ok((manifest, generation_chunks, audit_chunks)) + } +} + +fn build_generation_chunks( + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + generation_commitment: Digest, + component: ProviderExportComponent, + items: Vec>, +) -> Result, IdentityError> { + let groups = chunk_item_groups(items)?; + let mut start_index = 0_u64; + let mut chunks = Vec::with_capacity(groups.len()); + for (ordinal, group) in groups.into_iter().enumerate() { + let item_count = + u64::try_from(group.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider generation chunk item count", + })?; + let end_index = + start_index + .checked_add(item_count) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider generation chunk end", + })?; + let item_payload_bytes = canonical_item_bytes(&group)?; + let payload = encode_chunk_items(group)?; + let chunk = ProviderGenerationExportChunk { + format_version: 1, + provider_id, + log_id, + key_version, + generation_commitment, + component_code: component.code(), + ordinal: u32::try_from(ordinal).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider generation chunk ordinal", + })?, + start_index, + end_index, + item_payload_bytes, + payload: BoundedBytes::new("provider generation chunk payload", payload)?, + }; + chunk.to_canonical_bytes()?; + chunks.push(chunk); + start_index = end_index; + } + Ok(chunks) +} + +fn build_audit_chunks( + provider_id: ProviderId, + log_id: ProviderLogId, + audit_commitment: Digest, + items: Vec>, +) -> Result, IdentityError> { + let groups = chunk_item_groups(items)?; + let mut start_sequence = 1_u64; + let mut chunks = Vec::with_capacity(groups.len()); + for (ordinal, group) in groups.into_iter().enumerate() { + let item_count = + u64::try_from(group.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit chunk item count", + })?; + let end_sequence = + start_sequence + .checked_add(item_count) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider audit chunk end sequence", + })?; + let item_payload_bytes = canonical_item_bytes(&group)?; + let payload = encode_chunk_items(group)?; + let chunk = ProviderAuditExportChunk { + format_version: 1, + provider_id, + log_id, + audit_commitment, + ordinal: u32::try_from(ordinal).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider audit chunk ordinal", + })?, + start_sequence, + end_sequence, + item_payload_bytes, + payload: BoundedBytes::new("provider audit chunk payload", payload)?, + }; + chunk.to_canonical_bytes()?; + chunks.push(chunk); + start_sequence = end_sequence; + } + Ok(chunks) +} + +fn encode_generation_component_items( + export: &ProviderGenerationExport, + component: ProviderExportComponent, +) -> Result>, IdentityError> { + match component { + ProviderExportComponent::Entries => export + .entries + .iter() + .map(|entry| entry.to_canonical_bytes()) + .collect(), + ProviderExportComponent::LeafHashes => export + .leaf_hashes + .iter() + .map(|leaf_hash| leaf_hash.to_canonical_bytes()) + .collect(), + ProviderExportComponent::Receipts => export + .receipts + .iter() + .map(|receipt| receipt.to_canonical_bytes()) + .collect(), + ProviderExportComponent::CheckpointBundles => export + .checkpoint_bundles + .iter() + .map(|bundle| { + encode_wire(&ProviderCheckpointBundleItemWire { + format_version: 1, + bundle: ProviderCheckpointBundleWire::from_bundle(bundle)?, + }) + }) + .collect(), + ProviderExportComponent::CompactionManifests => export + .compaction_manifests + .iter() + .map(|manifest| manifest.to_canonical_bytes()) + .collect(), + } +} + +pub(super) fn validate_checkpoint_bundle_interchange_item( + bundle: &crate::ProviderCheckpointBundle, +) -> Result<(), IdentityError> { + checkpoint_bundle_item_bytes(bundle).and_then(validate_single_item_bytes) +} + +pub(super) fn validate_generation_interchange_bounds( + export: &ProviderGenerationExport, +) -> Result<(), IdentityError> { + ProviderGenerationPortableAccounting::from_export(export).map(|_| ()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PortableComponentAccounting { + items: usize, + bytes: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ProviderGenerationPortableAccounting { + entries: PortableComponentAccounting, + leaf_hashes: PortableComponentAccounting, + receipts: PortableComponentAccounting, + checkpoint_bundles: PortableComponentAccounting, + compaction_manifests: PortableComponentAccounting, + total_bytes: usize, +} + +impl ProviderGenerationPortableAccounting { + pub(super) const fn empty() -> Self { + let empty = PortableComponentAccounting { items: 0, bytes: 0 }; + Self { + entries: empty, + leaf_hashes: empty, + receipts: empty, + checkpoint_bundles: empty, + compaction_manifests: empty, + total_bytes: 0, + } + } + + pub(super) fn from_export(export: &ProviderGenerationExport) -> Result { + let mut accounting = Self::empty(); + for entry in &export.entries { + accounting = accounting.with_appended_entry(entry)?; + } + for leaf_hash in &export.leaf_hashes { + accounting = accounting.with_appended_leaf_hash(leaf_hash)?; + } + for receipt in &export.receipts { + accounting = accounting.with_appended_receipt(receipt)?; + } + for bundle in &export.checkpoint_bundles { + accounting = accounting.with_appended_checkpoint_bundle(bundle)?; + } + for manifest in &export.compaction_manifests { + accounting = accounting.with_appended_compaction_manifest(manifest)?; + } + Ok(accounting) + } + + pub(super) fn with_appended_entry( + self, + entry: &ProviderLogEntryBody, + ) -> Result { + self.with_appended_bytes( + ProviderExportComponent::Entries, + encoded_canonical_item_len(entry)?, + ) + } + + pub(super) fn with_appended_leaf_hash(self, leaf_hash: &Digest) -> Result { + self.with_appended_bytes( + ProviderExportComponent::LeafHashes, + encoded_canonical_item_len(leaf_hash)?, + ) + } + + pub(super) fn with_appended_receipt( + self, + receipt: &InclusionReceipt, + ) -> Result { + self.with_appended_bytes( + ProviderExportComponent::Receipts, + encoded_canonical_item_len(receipt)?, + ) + } + + pub(super) fn with_replaced_receipt( + self, + old: &InclusionReceipt, + new: &InclusionReceipt, + ) -> Result { + self.with_replaced_bytes( + ProviderExportComponent::Receipts, + encoded_canonical_item_len(old)?, + encoded_canonical_item_len(new)?, + ) + } + + pub(super) fn with_appended_checkpoint_bundle( + self, + bundle: &crate::ProviderCheckpointBundle, + ) -> Result { + self.with_appended_bytes( + ProviderExportComponent::CheckpointBundles, + checkpoint_bundle_item_bytes(bundle)?, + ) + } + + pub(super) fn with_replaced_checkpoint_bundle( + self, + old: &crate::ProviderCheckpointBundle, + new: &crate::ProviderCheckpointBundle, + ) -> Result { + self.with_replaced_bytes( + ProviderExportComponent::CheckpointBundles, + checkpoint_bundle_item_bytes(old)?, + checkpoint_bundle_item_bytes(new)?, + ) + } + + pub(super) fn with_appended_compaction_manifest( + self, + manifest: &ProviderCompactionManifest, + ) -> Result { + self.with_appended_bytes( + ProviderExportComponent::CompactionManifests, + encoded_canonical_item_len(manifest)?, + ) + } + + fn with_appended_bytes( + mut self, + component: ProviderExportComponent, + item_bytes: usize, + ) -> Result { + validate_single_item_bytes(item_bytes)?; + let item_limit = component_item_limit(component); + let component_accounting = self.component_mut(component); + let next_items = + component_accounting + .items + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "portable provider generation component items", + })?; + if next_items > item_limit { + return Err(IdentityError::limit( + "portable provider generation component items", + next_items, + item_limit, + )); + } + component_accounting.bytes = component_accounting.bytes.checked_add(item_bytes).ok_or( + IdentityError::ArithmeticOverflow { + resource: "portable provider generation component bytes", + }, + )?; + component_accounting.items = next_items; + self.total_bytes = checked_portable_total_add(self.total_bytes, item_bytes)?; + Ok(self) + } + + fn with_replaced_bytes( + mut self, + component: ProviderExportComponent, + old_item_bytes: usize, + new_item_bytes: usize, + ) -> Result { + validate_single_item_bytes(old_item_bytes)?; + validate_single_item_bytes(new_item_bytes)?; + let component_accounting = self.component_mut(component); + if component_accounting.items == 0 { + return Err(IdentityError::StorageCorruption); + } + component_accounting.bytes = component_accounting + .bytes + .checked_sub(old_item_bytes) + .and_then(|bytes| bytes.checked_add(new_item_bytes)) + .ok_or(IdentityError::StorageCorruption)?; + self.total_bytes = self + .total_bytes + .checked_sub(old_item_bytes) + .ok_or(IdentityError::StorageCorruption)?; + self.total_bytes = checked_portable_total_add(self.total_bytes, new_item_bytes)?; + Ok(self) + } + + fn component_mut( + &mut self, + component: ProviderExportComponent, + ) -> &mut PortableComponentAccounting { + match component { + ProviderExportComponent::Entries => &mut self.entries, + ProviderExportComponent::LeafHashes => &mut self.leaf_hashes, + ProviderExportComponent::Receipts => &mut self.receipts, + ProviderExportComponent::CheckpointBundles => &mut self.checkpoint_bundles, + ProviderExportComponent::CompactionManifests => &mut self.compaction_manifests, + } + } +} + +fn encoded_canonical_item_len(item: &T) -> Result { + record_portable_item_encoding(); + item.to_canonical_bytes().map(|bytes| bytes.len()) +} + +fn checkpoint_bundle_item_bytes( + bundle: &crate::ProviderCheckpointBundle, +) -> Result { + record_portable_item_encoding(); + encode_wire(&ProviderCheckpointBundleItemWire { + format_version: 1, + bundle: ProviderCheckpointBundleWire::from_bundle(bundle)?, + }) + .map(|bytes| bytes.len()) +} + +fn checked_portable_total_add(total: usize, item_bytes: usize) -> Result { + let next_total = total + .checked_add(item_bytes) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "portable provider generation bytes", + })?; + if next_total > MAX_PROVIDER_PORTABLE_GENERATION_BYTES { + return Err(IdentityError::limit( + "portable provider generation bytes", + next_total, + MAX_PROVIDER_PORTABLE_GENERATION_BYTES, + )); + } + Ok(next_total) +} + +pub(crate) fn validate_audit_interchange_bounds( + snapshot: &ProviderAuditSnapshot, +) -> Result<(), IdentityError> { + ProviderAuditPortableAccounting::from_snapshot(snapshot).map(|_| ()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProviderAuditPortableAccounting { + records: usize, + total_bytes: usize, +} + +impl ProviderAuditPortableAccounting { + pub(crate) const fn empty() -> Self { + Self { + records: 0, + total_bytes: 0, + } + } + + pub(crate) fn from_snapshot(snapshot: &ProviderAuditSnapshot) -> Result { + let mut accounting = Self::empty(); + for record in snapshot.records() { + accounting = accounting.with_appended_record(record)?; + } + Ok(accounting) + } + + pub(crate) fn with_appended_record( + self, + record: &ProviderAuditRecord, + ) -> Result { + let next_records = + self.records + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "portable provider audit records", + })?; + if next_records > MAX_PROVIDER_AUDIT_RECORDS { + return Err(IdentityError::limit( + "portable provider audit records", + next_records, + MAX_PROVIDER_AUDIT_RECORDS, + )); + } + let record_bytes = audit_record_item_bytes(record)?; + validate_single_item_bytes(record_bytes)?; + let total_bytes = self.total_bytes.checked_add(record_bytes).ok_or( + IdentityError::ArithmeticOverflow { + resource: "portable provider audit bytes", + }, + )?; + if total_bytes > MAX_PROVIDER_PORTABLE_AUDIT_BYTES { + return Err(IdentityError::limit( + "portable provider audit bytes", + total_bytes, + MAX_PROVIDER_PORTABLE_AUDIT_BYTES, + )); + } + Ok(Self { + records: next_records, + total_bytes, + }) + } +} + +fn audit_record_item_bytes(record: &ProviderAuditRecord) -> Result { + record_portable_audit_record_encoding(); + encode_wire(&ProviderAuditRecordItemWire { + format_version: 1, + record: ProviderAuditRecordWire::from_record(record), + }) + .map(|bytes| bytes.len()) +} + +fn validate_single_item_bytes(item_bytes: usize) -> Result<(), IdentityError> { + if item_bytes > MAX_PROVIDER_EXPORT_ITEM_BYTES { + return Err(IdentityError::limit( + "provider export single canonical item bytes", + item_bytes, + MAX_PROVIDER_EXPORT_ITEM_BYTES, + )); + } + Ok(()) +} + +fn validate_generation_item( + component: ProviderExportComponent, + bytes: &[u8], +) -> Result<(), IdentityError> { + match component { + ProviderExportComponent::Entries => { + ProviderLogEntryBody::from_canonical_bytes(bytes).map(|_| ()) + } + ProviderExportComponent::LeafHashes => Digest::from_canonical_bytes(bytes).map(|_| ()), + ProviderExportComponent::Receipts => { + InclusionReceipt::from_canonical_bytes(bytes).map(|_| ()) + } + ProviderExportComponent::CheckpointBundles => { + decode_checkpoint_bundle_item(bytes).map(|_| ()) + } + ProviderExportComponent::CompactionManifests => { + ProviderCompactionManifest::from_canonical_bytes(bytes).map(|_| ()) + } + } +} + +fn decode_checkpoint_bundle_item( + bytes: &[u8], +) -> Result { + let item: ProviderCheckpointBundleItemWire = decode_wire(bytes)?; + require_version(item.format_version)?; + item.bundle.validate_interchange_shape()?; + Ok(item.bundle) +} + +fn decode_audit_record_item(bytes: &[u8]) -> Result { + let item: ProviderAuditRecordItemWire = decode_wire(bytes)?; + require_version(item.format_version)?; + item.record.clone().into_record()?; + Ok(item.record) +} + +fn chunk_item_groups(items: Vec>) -> Result>>, IdentityError> { + let mut groups = Vec::>>::new(); + let mut current = Vec::>::new(); + let mut current_bytes = 0_usize; + for item in items { + validate_single_item_bytes(item.len())?; + let next_bytes = + current_bytes + .checked_add(item.len()) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider export chunk item bytes", + })?; + if !current.is_empty() + && (current.len() == MAX_PROVIDER_EXPORT_CHUNK_ITEMS + || next_bytes > MAX_PROVIDER_EXPORT_CHUNK_PAYLOAD_BYTES.saturating_sub(1024)) + { + groups.push(std::mem::take(&mut current)); + current_bytes = 0; + } + current_bytes = + current_bytes + .checked_add(item.len()) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider export chunk item bytes", + })?; + current.push(item); + } + if !current.is_empty() { + groups.push(current); + } + if groups.len() > MAX_PROVIDER_EXPORT_CHUNKS { + return Err(IdentityError::limit( + "provider export chunks", + groups.len(), + MAX_PROVIDER_EXPORT_CHUNKS, + )); + } + Ok(groups) +} + +fn encode_chunk_items(items: Vec>) -> Result, IdentityError> { + let items = items + .into_iter() + .map(|item| BoundedBytes::new("provider export canonical item", item)) + .collect::, _>>()?; + encode_wire(&ChunkItems::new("provider export chunk items", items)?) +} + +fn decode_chunk_items(bytes: &[u8]) -> Result>, IdentityError> { + Ok(decode_wire::(bytes)? + .into_vec() + .into_iter() + .map(BoundedBytes::into_vec) + .collect()) +} + +fn canonical_item_bytes(items: &[Vec]) -> Result { + items.iter().try_fold(0_u64, |total, item| { + total + .checked_add(u64::try_from(item.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider export canonical item bytes", + } + })?) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider export canonical payload bytes", + }) + }) +} + +fn decode_generation_component( + manifest: &ProviderGenerationExportManifest, + pending: &BTreeMap<(u16, u32), ProviderGenerationExportChunk>, + component: ProviderExportComponent, +) -> Result, IdentityError> { + let descriptor = manifest.descriptor(component)?; + let capacity = + usize::try_from(descriptor.item_count).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider generation component allocation", + })?; + let mut values = Vec::with_capacity(capacity); + let mut expected_start = 0_u64; + let mut commitments = + Vec::with_capacity(usize::try_from(descriptor.chunk_count).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider generation chunk commitment allocation", + } + })?); + for ordinal in 0..descriptor.chunk_count { + let chunk = pending.get(&(component.code(), ordinal)).ok_or( + IdentityError::InvalidRelationship { + resource: "provider generation chunk ordinal gap", + }, + )?; + if chunk.start_index != expected_start { + return Err(IdentityError::InvalidRelationship { + resource: "provider generation chunk range gap or overlap", + }); + } + expected_start = chunk.end_index; + commitments.push(chunk.commitment()?); + values.extend( + chunk + .item_bytes()? + .into_iter() + .map(|bytes| T::from_canonical_bytes(&bytes)) + .collect::, _>>()?, + ); + } + validate_component_finish(descriptor, expected_start, component, &commitments)?; + Ok(values) +} + +fn decode_checkpoint_bundle_component( + manifest: &ProviderGenerationExportManifest, + pending: &BTreeMap<(u16, u32), ProviderGenerationExportChunk>, +) -> Result, IdentityError> { + let component = ProviderExportComponent::CheckpointBundles; + let descriptor = manifest.descriptor(component)?; + let mut wires = Vec::with_capacity(usize::try_from(descriptor.item_count).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "provider checkpoint bundle allocation", + } + })?); + let mut expected_start = 0_u64; + let mut commitments = Vec::new(); + for ordinal in 0..descriptor.chunk_count { + let chunk = pending.get(&(component.code(), ordinal)).ok_or( + IdentityError::InvalidRelationship { + resource: "provider checkpoint bundle chunk gap", + }, + )?; + if chunk.start_index != expected_start { + return Err(IdentityError::InvalidRelationship { + resource: "provider checkpoint bundle chunk range gap or overlap", + }); + } + expected_start = chunk.end_index; + commitments.push(chunk.commitment()?); + wires.extend( + chunk + .item_bytes()? + .into_iter() + .map(|bytes| decode_checkpoint_bundle_item(&bytes)) + .collect::, _>>()?, + ); + } + validate_component_finish(descriptor, expected_start, component, &commitments)?; + super::decode_provider_checkpoint_bundle_wires(&wires) +} + +fn validate_component_finish( + descriptor: &ProviderExportComponentDescriptor, + expected_end: u64, + component: ProviderExportComponent, + commitments: &[Digest], +) -> Result<(), IdentityError> { + if expected_end != descriptor.item_count + || ordered_chunk_list_commitment( + GENERATION_CHUNK_LIST_DOMAIN, + component.code(), + commitments, + )? != descriptor.chunk_list_commitment + { + return Err(IdentityError::InvalidProof); + } + Ok(()) +} + +fn component_item_limit(component: ProviderExportComponent) -> usize { + match component { + ProviderExportComponent::Entries + | ProviderExportComponent::LeafHashes + | ProviderExportComponent::Receipts + | ProviderExportComponent::CheckpointBundles => MAX_MERKLE_LOG_LEAVES, + ProviderExportComponent::CompactionManifests => MAX_PROVIDER_COMPACTION_MANIFESTS, + } +} + +fn require_version(format_version: u16) -> Result<(), IdentityError> { + if format_version != 1 { + return Err(IdentityError::UnsupportedVersion { + version: format_version, + }); + } + Ok(()) +} + +fn check_chunk_size(bytes: Vec) -> Result, IdentityError> { + if bytes.len() > MAX_PROVIDER_EXPORT_CHUNK_BYTES { + return Err(IdentityError::limit( + "provider interchange chunk bytes", + bytes.len(), + MAX_PROVIDER_EXPORT_CHUNK_BYTES, + )); + } + Ok(bytes) +} + +fn domain_commitment(domain: &[u8], value: &T) -> Result { + let bytes = encode_wire(value)?; + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(&bytes); + Ok(Digest::new( + HashAlgorithm::Blake3_256, + *hasher.finalize().as_bytes(), + )) +} + +fn ordered_chunk_list_commitment( + domain: &[u8], + component_code: u16, + commitments: &[Digest], +) -> Result { + let chunk_count = + u32::try_from(commitments.len()).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider chunk-list commitment count", + })?; + for commitment in commitments { + if commitment.algorithm() != HashAlgorithm::Blake3_256 { + return Err(IdentityError::InvalidRelationship { + resource: "provider chunk-list digest algorithm", + }); + } + } + domain_commitment( + domain, + &ProviderChunkListCommitmentWire { + format_version: 1, + component_code, + chunk_count, + commitments, + }, + ) +} diff --git a/protocols/krikos-identity/src/provider/redb.rs b/protocols/krikos-identity/src/provider/redb.rs new file mode 100644 index 00000000000..d149a0762c1 --- /dev/null +++ b/protocols/krikos-identity/src/provider/redb.rs @@ -0,0 +1,2632 @@ +//! Optional redb-backed provider generation with durable prepare/sign/commit sequencing. + +use std::{ + collections::BTreeMap, + path::Path, + sync::{Arc, Mutex, MutexGuard}, +}; + +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; + +use super::{ + MAX_PROVIDER_COMPACTION_MANIFESTS, MemoryProviderStore, ProviderAccountHistoryPage, + ProviderAppendPermit, ProviderCheckpointBundleWire, ProviderCheckpointIndex, + ProviderCompactionAuthorization, ProviderCompactionManifest, ProviderGenerationExport, + ProviderGenerationPayload, ProviderGenerationSnapshot, ProviderGenerationState, + ProviderRecoveryExport, ProviderRetentionClass, ProviderRetentionInventory, + ProviderRetentionItem, +}; +use crate::{ + AccountId, CheckpointId, Digest, Epoch, EventId, Extensions, IdentityError, InclusionReceipt, + ProtocolSignature, ProviderAuditArtifact, ProviderAuditArtifactKind, ProviderCheckpointBundle, + ProviderDescriptor, ProviderHeadBody, ProviderHeadSigner, ProviderKeyVersion, + ProviderLogEntryBody, ProviderLogId, Sequence, SignedProviderHead, Timestamp, + codec::{decode_wire, encode_wire}, + limits::{MAX_FORK_HEADS, MAX_MERKLE_LOG_LEAVES}, + merkle::{AppendOnlyMerkleLog, MerkleConsistencyProof}, + schema::{BoundedBytes, BoundedVec}, +}; + +const COMMITTED_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-provider-generation-v1"); +const PREPARED_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-provider-prepared-v1"); +const ACTIVE_KEY: &[u8] = b"active"; +const PREPARED_KEY: &[u8] = b"append"; +const STORE_VERSION: u16 = 7; +const MAX_STORED_PROVIDER_ENTRIES: usize = 65_536; +const MAX_STORED_PROVIDER_NODES: usize = MAX_STORED_PROVIDER_ENTRIES * 2; +const MAX_STORED_PROVIDER_BYTES: usize = 512 * 1024 * 1024; +const MAX_STORED_PROVIDER_AUDIT_BYTES: usize = 256 * 1024 * 1024; +const MAX_FRONTIER_NODES: usize = u64::BITS as usize; +const PREPARED_OWNER_TOKEN_FORMAT_VERSION: u16 = 1; +const PREPARED_OWNER_TOKEN_DOMAIN: &[u8] = b"KRIKOS-ID/provider-prepared-owner/v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct AccountIndexWire { + account_id: AccountId, + leaf_indices: BoundedVec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct FrontierNodeWire { + level: u8, + root: Digest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct MerkleNodeWire { + start: u64, + size: u64, + root: Digest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderCheckpointIndexWire { + account_id: AccountId, + greatest_sequence: Sequence, + greatest_epoch: Epoch, + current_checkpoint_id: Option, + projection_heads: BoundedVec, + forked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct GenerationMaterialWire { + entries: BoundedVec, + leaf_hashes: BoundedVec, + account_index: BoundedVec, + frontier: BoundedVec, + nodes: BoundedVec, + checkpoint_bundles: BoundedVec, + checkpoint_index: BoundedVec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct StoredProviderWire { + version: u16, + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + latest_head: Option, + compaction_manifests: BoundedVec, + payload: ProviderPayloadWire, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +enum ProviderPayloadWire { + Active { + material: GenerationMaterialWire, + receipts: BoundedVec, + }, + Sealed(Box), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct RetainedProviderRecordWire { + leaf_index: u64, + entry: ProviderLogEntryBody, + receipt: InclusionReceipt, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SealedProviderWire { + leaf_hashes: BoundedVec, + frontier: BoundedVec, + nodes: BoundedVec, + retained_records: BoundedVec, + checkpoint_bundles: BoundedVec, + checkpoint_index: BoundedVec, + manifest: Option, + inventory: Option, + audit_snapshot: Option>, + audit_artifacts: BoundedVec, + archive_complete: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderRetentionItemWire { + leaf_index: u64, + class_code: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderAuditArtifactWire { + sequence: u64, + kind_code: u16, + accepted_head: SignedProviderHead, + observed_head: SignedProviderHead, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ProviderRetentionInventoryWire { + tree_size: u64, + items: BoundedVec, + audit_artifacts: BoundedVec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PreparedAppendWire { + version: u16, + owner_token: [u8; 32], + base_tree_size: u64, + base_tree_root: Digest, + requested_observed_at: Timestamp, + leaf_index: u64, + material: GenerationMaterialWire, + stage: PreparedAppendStage, +} + +/// Durable append state. Once a signer may have seen the exact head body, the +/// append can no longer be cancelled or replaced. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +enum PreparedAppendStage { + /// Candidate material exists, but no external signer has been invoked. + Prepared, + /// The exact head body was durably bound before invoking the signer. + Signing { body: ProviderHeadBody }, + /// A verified signature was durably captured and awaits atomic promotion. + Signed { head: SignedProviderHead }, +} + +#[derive(Serialize)] +struct PreparedOwnerTokenPreimage<'a> { + format_version: u16, + base_root: Digest, + base_size: u64, + material: &'a GenerationMaterialWire, + leaf_index: u64, + observed_at: Timestamp, +} + +impl PreparedAppendWire { + fn signing_body(&self) -> Result<&ProviderHeadBody, IdentityError> { + match &self.stage { + PreparedAppendStage::Signing { body } => Ok(body), + PreparedAppendStage::Prepared | PreparedAppendStage::Signed { .. } => { + Err(IdentityError::ResourceBusy) + } + } + } + + fn signed_head(&self) -> Result<&SignedProviderHead, IdentityError> { + match &self.stage { + PreparedAppendStage::Signed { head } => Ok(head), + PreparedAppendStage::Prepared | PreparedAppendStage::Signing { .. } => { + Err(IdentityError::ResourceBusy) + } + } + } +} + +impl ProviderCheckpointBundleWire { + fn from_retained(retained: &super::RetainedCheckpointMaterial) -> Result { + Ok(Self { + genesis: retained.genesis.clone(), + prior_checkpoint_id: retained.prior_checkpoint_id, + events: BoundedVec::new( + "stored retained checkpoint lineage events", + retained.events.clone(), + )?, + checkpoint: retained.checkpoint.clone(), + transition_event: retained.transition_event.clone(), + }) + } + + fn into_retained(self) -> super::RetainedCheckpointMaterial { + super::RetainedCheckpointMaterial { + genesis: self.genesis, + prior_checkpoint_id: self.prior_checkpoint_id, + events: self.events.into_vec(), + checkpoint: self.checkpoint, + transition_event: self.transition_event, + } + } +} + +impl ProviderCheckpointIndexWire { + fn from_index(index: &ProviderCheckpointIndex) -> Result { + Ok(Self { + account_id: index.account_id, + greatest_sequence: index.greatest_sequence, + greatest_epoch: index.greatest_epoch, + current_checkpoint_id: index.current_checkpoint_id, + projection_heads: BoundedVec::new( + "stored provider checkpoint projection heads", + index.projection_heads.clone(), + )?, + forked: index.forked, + }) + } + + fn as_index(&self) -> ProviderCheckpointIndex { + ProviderCheckpointIndex { + account_id: self.account_id, + greatest_sequence: self.greatest_sequence, + greatest_epoch: self.greatest_epoch, + current_checkpoint_id: self.current_checkpoint_id, + projection_heads: self.projection_heads.as_slice().to_vec(), + forked: self.forked, + } + } +} + +impl RetainedProviderRecordWire { + fn from_record(record: &super::RetainedProviderRecord) -> Self { + Self { + leaf_index: record.leaf_index, + entry: record.entry.clone(), + receipt: record.receipt.clone(), + } + } +} + +impl ProviderAuditArtifactWire { + fn from_artifact(artifact: &ProviderAuditArtifact) -> Self { + Self { + sequence: artifact.sequence(), + kind_code: artifact.kind().code(), + accepted_head: artifact.accepted_head().clone(), + observed_head: artifact.observed_head().clone(), + } + } + + fn into_artifact(self) -> Result { + ProviderAuditArtifact::new( + self.sequence, + ProviderAuditArtifactKind::from_code(self.kind_code)?, + self.accepted_head, + self.observed_head, + ) + .map_err(|_| IdentityError::StorageCorruption) + } +} + +impl ProviderRetentionInventoryWire { + fn from_inventory(inventory: &ProviderRetentionInventory) -> Result { + Ok(Self { + tree_size: inventory.tree_size(), + items: BoundedVec::new( + "stored provider retention items", + inventory + .items() + .iter() + .map(|item| ProviderRetentionItemWire { + leaf_index: item.leaf_index(), + class_code: item.class().code(), + }) + .collect(), + )?, + audit_artifacts: BoundedVec::new( + "stored provider retention audit artifacts", + inventory + .audit_artifacts() + .iter() + .map(ProviderAuditArtifactWire::from_artifact) + .collect(), + )?, + }) + } + + fn into_inventory(self) -> Result { + let items = self + .items + .into_vec() + .into_iter() + .map(|item| { + ProviderRetentionItem::new( + item.leaf_index, + ProviderRetentionClass::from_code(item.class_code)?, + ) + }) + .collect::, IdentityError>>()?; + let artifacts = self + .audit_artifacts + .into_vec() + .into_iter() + .map(ProviderAuditArtifactWire::into_artifact) + .collect::, IdentityError>>()?; + ProviderRetentionInventory::with_audit_artifacts(self.tree_size, items, artifacts) + .map_err(|_| IdentityError::StorageCorruption) + } +} + +impl SealedProviderWire { + fn from_payload( + payload: &super::SealedProviderPayload, + leaf_hashes: &[Digest], + ) -> Result { + let checkpoint_bundles = if payload.archive_complete { + payload + .checkpoint_bundles + .iter() + .map(ProviderCheckpointBundleWire::from_bundle) + .collect::, IdentityError>>()? + } else { + payload + .retained_checkpoint_evidence + .iter() + .map(ProviderCheckpointBundleWire::from_retained) + .collect::, IdentityError>>()? + }; + Ok(Self { + leaf_hashes: BoundedVec::new( + "stored sealed provider leaf hashes", + leaf_hashes.to_vec(), + )?, + frontier: BoundedVec::new( + "stored sealed provider Merkle frontier", + build_frontier(leaf_hashes)?, + )?, + nodes: BoundedVec::new( + "stored sealed provider Merkle nodes", + build_nodes(leaf_hashes)?, + )?, + retained_records: BoundedVec::new( + "stored sealed provider retained records", + payload + .retained_records + .iter() + .map(RetainedProviderRecordWire::from_record) + .collect(), + )?, + checkpoint_bundles: BoundedVec::new( + "stored sealed provider checkpoint bundles", + checkpoint_bundles, + )?, + checkpoint_index: BoundedVec::new( + "stored sealed provider checkpoint index", + payload + .checkpoint_index + .iter() + .map(ProviderCheckpointIndexWire::from_index) + .collect::, IdentityError>>()?, + )?, + manifest: payload.manifest.clone(), + inventory: payload + .inventory + .as_ref() + .map(ProviderRetentionInventoryWire::from_inventory) + .transpose()?, + audit_snapshot: payload + .audit_snapshot + .as_ref() + .map(crate::audit::encode_provider_audit_snapshot) + .transpose()? + .map(|bytes| BoundedBytes::new("stored provider archive audit snapshot", bytes)) + .transpose()?, + audit_artifacts: BoundedVec::new( + "stored sealed provider audit artifacts", + payload + .audit_artifacts + .iter() + .map(ProviderAuditArtifactWire::from_artifact) + .collect(), + )?, + archive_complete: payload.archive_complete, + }) + } + + fn into_parts(self) -> Result<(Vec, super::SealedProviderPayload), IdentityError> { + if self.frontier.as_slice() != build_frontier(self.leaf_hashes.as_slice())? + || self.nodes.as_slice() != build_nodes(self.leaf_hashes.as_slice())? + { + return Err(IdentityError::StorageCorruption); + } + let (checkpoint_bundles, retained_checkpoint_evidence) = if self.archive_complete { + ( + decode_checkpoint_bundles(self.checkpoint_bundles.as_slice())?, + Vec::new(), + ) + } else { + ( + Vec::new(), + self.checkpoint_bundles + .into_vec() + .into_iter() + .map(ProviderCheckpointBundleWire::into_retained) + .collect(), + ) + }; + let checkpoint_index = self + .checkpoint_index + .as_slice() + .iter() + .map(ProviderCheckpointIndexWire::as_index) + .collect::>(); + let mut retained_records = Vec::with_capacity(self.retained_records.len()); + for wire in self.retained_records.into_vec() { + retained_records.push(super::RetainedProviderRecord { + leaf_index: wire.leaf_index, + entry: wire.entry, + receipt: wire.receipt, + }); + } + let inventory = self + .inventory + .map(ProviderRetentionInventoryWire::into_inventory) + .transpose()?; + let audit_snapshot = self + .audit_snapshot + .map(|bytes| crate::audit::decode_provider_audit_snapshot(bytes.as_slice())) + .transpose()?; + let audit_artifacts = self + .audit_artifacts + .into_vec() + .into_iter() + .map(ProviderAuditArtifactWire::into_artifact) + .collect::, IdentityError>>()?; + Ok(( + self.leaf_hashes.into_vec(), + super::SealedProviderPayload { + retained_records, + checkpoint_bundles, + retained_checkpoint_evidence, + checkpoint_index, + manifest: self.manifest, + inventory, + audit_snapshot, + audit_artifacts, + archive_complete: self.archive_complete, + }, + )) + } +} + +fn decode_checkpoint_bundles( + wires: &[ProviderCheckpointBundleWire], +) -> Result, IdentityError> { + super::decode_provider_checkpoint_bundle_wires(wires) + .map_err(|_| IdentityError::StorageCorruption) +} + +impl GenerationMaterialWire { + fn from_parts( + entries: &[ProviderLogEntryBody], + leaf_hashes: &[Digest], + checkpoint_bundles: &[ProviderCheckpointBundle], + ) -> Result { + if entries.len() > MAX_STORED_PROVIDER_ENTRIES { + return Err(IdentityError::limit( + "stored provider generation entries", + entries.len(), + MAX_STORED_PROVIDER_ENTRIES, + )); + } + if entries.len() != leaf_hashes.len() { + return Err(IdentityError::StorageCorruption); + } + let checkpoint_index = super::rebuild_checkpoint_index(entries, checkpoint_bundles)?; + Ok(Self { + entries: BoundedVec::new("stored provider entries", entries.to_vec())?, + leaf_hashes: BoundedVec::new("stored provider leaf hashes", leaf_hashes.to_vec())?, + account_index: BoundedVec::new( + "stored provider account index", + build_account_index(entries)?, + )?, + frontier: BoundedVec::new( + "stored provider Merkle frontier", + build_frontier(leaf_hashes)?, + )?, + nodes: BoundedVec::new("stored provider Merkle nodes", build_nodes(leaf_hashes)?)?, + checkpoint_bundles: BoundedVec::new( + "stored provider checkpoint bundles", + checkpoint_bundles + .iter() + .map(ProviderCheckpointBundleWire::from_bundle) + .collect::, IdentityError>>()?, + )?, + checkpoint_index: BoundedVec::new( + "stored provider checkpoint index", + checkpoint_index + .iter() + .map(ProviderCheckpointIndexWire::from_index) + .collect::, IdentityError>>()?, + )?, + }) + } + + fn validate( + &self, + provider: &ProviderDescriptor, + log_id: ProviderLogId, + ) -> Result<(), IdentityError> { + if self.entries.len() != self.leaf_hashes.len() { + return Err(IdentityError::StorageCorruption); + } + let provider_id = provider.id()?; + for (entry, leaf_hash) in self + .entries + .as_slice() + .iter() + .zip(self.leaf_hashes.as_slice()) + { + if entry.provider_id() != provider_id + || entry.log_id() != log_id + || entry.merkle_leaf_hash()? != *leaf_hash + { + return Err(IdentityError::StorageCorruption); + } + } + let checkpoint_bundles = decode_checkpoint_bundles(self.checkpoint_bundles.as_slice())?; + let checkpoint_index = + super::rebuild_checkpoint_index(self.entries.as_slice(), &checkpoint_bundles)?; + if self.account_index.as_slice() != build_account_index(self.entries.as_slice())? + || self.frontier.as_slice() != build_frontier(self.leaf_hashes.as_slice())? + || self.nodes.as_slice() != build_nodes(self.leaf_hashes.as_slice())? + || self.checkpoint_index.as_slice() + != checkpoint_index + .iter() + .map(ProviderCheckpointIndexWire::from_index) + .collect::, IdentityError>>()? + { + return Err(IdentityError::StorageCorruption); + } + Ok(()) + } +} + +impl StoredProviderWire { + fn from_state(state: &ProviderGenerationState) -> Result { + state.validate_cached()?; + let payload = match &state.payload { + super::ProviderGenerationPayload::Active(payload) => ProviderPayloadWire::Active { + material: GenerationMaterialWire::from_parts( + &payload.entries, + &state.leaf_hashes, + &payload.checkpoint_bundles, + )?, + receipts: BoundedVec::new("stored provider receipts", payload.receipts.clone())?, + }, + super::ProviderGenerationPayload::Sealed(payload) => { + ProviderPayloadWire::Sealed(Box::new(SealedProviderWire::from_payload( + payload, + &state.leaf_hashes, + )?)) + } + }; + Ok(Self { + version: STORE_VERSION, + provider: state.provider.clone(), + log_id: state.log_id, + key_version: state.key_version, + latest_head: state.latest_head.clone(), + compaction_manifests: BoundedVec::new( + "stored provider compaction manifests", + state.compaction_manifests.clone(), + )?, + payload, + }) + } + + fn into_state(self) -> Result { + self.into_state_with_portable_validation(true) + } + + fn into_state_cached(self) -> Result { + self.into_state_with_portable_validation(false) + } + + fn into_state_with_portable_validation( + self, + validate_portable_bytes: bool, + ) -> Result { + if self.version != STORE_VERSION || self.key_version != ProviderKeyVersion::GENESIS { + return Err(IdentityError::StorageCorruption); + } + let (leaf_hashes, payload) = match self.payload { + ProviderPayloadWire::Active { material, receipts } => { + material.validate(&self.provider, self.log_id)?; + let checkpoint_bundles = + decode_checkpoint_bundles(material.checkpoint_bundles.as_slice())?; + let checkpoint_index = material + .checkpoint_index + .as_slice() + .iter() + .map(ProviderCheckpointIndexWire::as_index) + .collect(); + let leaf_hashes = material.leaf_hashes.into_vec(); + ( + leaf_hashes, + super::ProviderGenerationPayload::Active(super::ActiveProviderPayload { + entries: material.entries.into_vec(), + receipts: receipts.into_vec(), + checkpoint_bundles, + checkpoint_index, + }), + ) + } + ProviderPayloadWire::Sealed(sealed) => { + let (leaf_hashes, sealed) = (*sealed).into_parts()?; + ( + leaf_hashes, + super::ProviderGenerationPayload::Sealed(Box::new(sealed)), + ) + } + }; + let state = ProviderGenerationState { + provider: self.provider, + log_id: self.log_id, + key_version: self.key_version, + leaf_hashes, + latest_head: self.latest_head, + compaction_manifests: self.compaction_manifests.into_vec(), + payload, + }; + if validate_portable_bytes { + state.validate()?; + } else { + state.validate_cached()?; + } + Ok(state) + } +} + +/// Redb-backed provider store with an explicit crash-recoverable signing boundary. +#[derive(Debug, Clone)] +pub struct RedbProviderStore { + database: Arc, + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + portable_accounting: Arc>, +} + +impl RedbProviderStore { + /// Open or create one exact provider/log/key generation and authenticate all durable indices. + pub fn open( + path: impl AsRef, + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + ) -> Result { + if key_version != ProviderKeyVersion::GENESIS { + return Err(IdentityError::InvalidRelationship { + resource: "provider signing-key generation", + }); + } + let path = path.as_ref(); + crate::redb_guard::validate_existing_redb_file(path)?; + let database = Database::create(path).map_err(|_| IdentityError::StorageCorruption)?; + let requested = ProviderGenerationState { + provider: provider.clone(), + log_id, + key_version, + leaf_hashes: Vec::new(), + latest_head: None, + compaction_manifests: Vec::new(), + payload: super::ProviderGenerationPayload::Active(super::ActiveProviderPayload { + entries: Vec::new(), + receipts: Vec::new(), + checkpoint_bundles: Vec::new(), + checkpoint_index: Vec::new(), + }), + }; + let write = database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let stored = { + let mut table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let existing = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| decode_state(value.value())) + .transpose()?; + match existing { + Some(state) => state, + None => { + let bytes = encode_state(&requested)?; + table + .insert(ACTIVE_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + requested + } + } + }; + if stored.provider != provider + || stored.log_id != log_id + || stored.key_version != key_version + { + return Err(IdentityError::InvalidRelationship { + resource: "provider store generation", + }); + } + let portable_accounting = portable_accounting_for_committed_state(&stored)?; + // Ensure read-only recovery can distinguish an empty pending slot from a missing table. + { + let _prepared = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + let store = Self { + database: Arc::new(database), + provider, + log_id, + key_version, + portable_accounting: Arc::new(Mutex::new(portable_accounting)), + }; + store.recover_prepared_append()?; + Ok(store) + } + + /// Restore a complete composite recovery export as an immutable redb archive. + /// + /// Repeating the restore with the exact same archive is idempotent. Existing different state + /// or any prepared append is never overwritten. + pub fn restore_recovery( + path: impl AsRef, + recovery: ProviderRecoveryExport, + ) -> Result { + let state = super::recovery_archive_state(recovery)?; + let portable_accounting = portable_accounting_for_committed_state(&state)?; + let provider = state.provider.clone(); + let log_id = state.log_id; + let key_version = state.key_version; + let path = path.as_ref(); + crate::redb_guard::validate_existing_redb_file(path)?; + let database = Database::create(path).map_err(|_| IdentityError::StorageCorruption)?; + let write = database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + { + let prepared = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + if prepared + .get(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .is_some() + { + return Err(IdentityError::ResourceBusy); + } + } + { + let mut table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let existing = { + let value = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)?; + value + .map(|retained| decode_state(retained.value())) + .transpose()? + }; + match existing { + Some(existing) if existing != state => { + return Err(IdentityError::InvalidRelationship { + resource: "provider recovery archive destination", + }); + } + Some(_) => {} + None => { + let bytes = encode_state(&state)?; + table + .insert(ACTIVE_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + } + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(Self { + database: Arc::new(database), + provider, + log_id, + key_version, + portable_accounting: Arc::new(Mutex::new(portable_accounting)), + }) + } + + /// Return the complete authenticated summary of the committed generation. + pub fn snapshot(&self) -> Result { + self.memory_view()?.snapshot() + } + + /// Return this store's exact immutable provider/log/key address. + pub fn generation_route(&self) -> Result { + super::ProviderGenerationRoute::new(&self.provider, self.log_id, self.key_version) + } + + /// Serve the unique current checkpoint bundle, failing closed after retained conflict. + pub fn latest_checkpoint_bundle( + &self, + account_id: AccountId, + ) -> Result, IdentityError> { + self.memory_view()?.latest_checkpoint_bundle(account_id) + } + + /// Fetch one exact retained checkpoint branch with its authenticated provider inclusion. + pub fn checkpoint_bundle( + &self, + account_id: AccountId, + checkpoint_id: CheckpointId, + ) -> Result, IdentityError> { + self.memory_view()? + .checkpoint_bundle(account_id, checkpoint_id) + } + + /// Fetch one bounded target-to-genesis lineage page from an explicit retained branch. + pub fn checkpoint_lineage_page( + &self, + account_id: AccountId, + start_checkpoint_id: CheckpointId, + maximum_records: usize, + maximum_bytes: usize, + ) -> Result, IdentityError> { + self.memory_view()?.checkpoint_lineage_page( + account_id, + start_checkpoint_id, + maximum_records, + maximum_bytes, + ) + } + + /// Fetch raw locally retained checkpoint evidence without minting an append capability. + pub fn retained_checkpoint_evidence( + &self, + account_id: AccountId, + checkpoint_id: CheckpointId, + ) -> Result, IdentityError> { + self.memory_view()? + .retained_checkpoint_evidence(account_id, checkpoint_id) + } + + /// Fetch the unique current raw checkpoint evidence from a locally sealed generation. + pub fn latest_retained_checkpoint_evidence( + &self, + account_id: AccountId, + ) -> Result, IdentityError> { + self.memory_view()? + .latest_retained_checkpoint_evidence(account_id) + } + + /// Return all non-leaf rollback/equivocation artifacts retained by a sealed generation. + pub fn retained_audit_artifacts(&self) -> Result, IdentityError> { + self.memory_view()?.retained_audit_artifacts() + } + + /// Append through durable prepare, signing-intent, signed-candidate, and visibility stages. + pub fn append( + &self, + permit: ProviderAppendPermit, + observed_at: Timestamp, + signer: &S, + ) -> Result { + let prepared = self.prepare_append(permit, observed_at)?; + let signing = self.begin_signing(prepared)?; + let signed = self.sign_and_persist(signing, signer)?; + self.promote_signed_prepared(signed) + } + + /// Resume an append whose signer may already have observed the exact bound head body. + /// + /// A retained signing candidate is never replaced: every retry signs the same body. A + /// durably signed candidate is promoted without another signing call. + pub fn resume_append( + &self, + signer: &S, + ) -> Result { + if matches!( + self.load_state()?.payload, + super::ProviderGenerationPayload::Sealed(_) + ) { + return Err(IdentityError::ProviderArchiveRequired); + } + let prepared = self.load_prepared()?.ok_or(IdentityError::ResourceBusy)?; + match &prepared.stage { + PreparedAppendStage::Prepared => Err(IdentityError::ResourceBusy), + PreparedAppendStage::Signing { .. } => { + let signed = self.sign_and_persist(prepared, signer)?; + self.promote_signed_prepared(signed) + } + PreparedAppendStage::Signed { .. } => self.promote_signed_prepared(prepared), + } + } + + /// Cancel only a candidate for which no signer was ever invoked. + pub fn cancel_prepared_append(&self) -> Result<(), IdentityError> { + if matches!( + self.load_state()?.payload, + super::ProviderGenerationPayload::Sealed(_) + ) { + return Err(IdentityError::ProviderArchiveRequired); + } + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let removable = { + let table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .get(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| decode_prepared(value.value())) + .transpose()? + .is_some_and(|prepared| matches!(prepared.stage, PreparedAppendStage::Prepared)) + }; + if !removable { + return Err(IdentityError::ResourceBusy); + } + { + let mut table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let _ = table + .remove(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write.commit().map_err(|_| IdentityError::StorageCorruption) + } + + /// Return a bounded account-filtered page from the durable account index. + pub fn account_history( + &self, + account_id: AccountId, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> Result { + self.memory_view()?.account_history( + account_id, + after_cursor, + maximum_records, + maximum_bytes, + ) + } + + /// Export one complete generation after reauthenticating every durable component. + pub fn export_generation(&self) -> Result { + self.memory_view()?.export_generation() + } + + /// Re-export the complete generation and audit journal from an immutable archive. + pub fn archived_recovery_export(&self) -> Result { + self.memory_view()?.archived_recovery_export() + } + + /// Return the complete audit history retained by an immutable archive. + pub fn archived_audit_snapshot(&self) -> Result { + self.memory_view()?.archived_audit_snapshot() + } + + /// Return every verified compaction manifest durably retained in this provider database. + pub fn compaction_manifests(&self) -> Result, IdentityError> { + let state = self.load_state()?; + state.validate_cached()?; + Ok(state.compaction_manifests) + } + + /// Atomically reverify and persist a compaction manifest before external release workflows. + pub fn record_compaction_manifest( + &self, + authorization: &ProviderCompactionAuthorization, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, + ) -> Result { + let mut portable_accounting = self.lock_portable_accounting()?; + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let manifest = authorization.manifest().clone(); + let mut next_portable_accounting = None; + { + let mut table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let mut state = { + let value = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + decode_state_cached(value.value())? + }; + if matches!(state.payload, ProviderGenerationPayload::Sealed(_)) { + return Err(IdentityError::ProviderArchiveRequired); + } + if !state.compaction_manifests.contains(&manifest) { + let mut generation = state.export()?; + generation + .compaction_manifests + .retain(|candidate| candidate != &manifest); + if &generation != mirror.generation() { + return Err(IdentityError::InvalidProof); + } + authorization.manifest().verify(mirror, mirror, inventory)?; + if state.compaction_manifests.len() == MAX_PROVIDER_COMPACTION_MANIFESTS { + return Err(IdentityError::limit( + "provider compaction manifests", + state.compaction_manifests.len().saturating_add(1), + MAX_PROVIDER_COMPACTION_MANIFESTS, + )); + } + next_portable_accounting = + Some((*portable_accounting).with_appended_compaction_manifest(&manifest)?); + state.compaction_manifests.push(manifest.clone()); + state.validate_cached()?; + let bytes = encode_state(&state)?; + table + .insert(ACTIVE_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + if let Some(accounting) = next_portable_accounting { + *portable_accounting = accounting; + } + Ok(manifest) + } + + /// Atomically and irreversibly replace active material with manifest-required retained state. + /// + /// The exact same verified mirror/inventory replay is idempotent. A pending append prevents + /// sealing so no prepared or already-signed candidate can cross the release boundary. + pub fn seal_after_verified_mirror( + &self, + authorization: &ProviderCompactionAuthorization, + mirror: &ProviderRecoveryExport, + inventory: &ProviderRetentionInventory, + ) -> Result { + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + { + let table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + if table + .get(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .is_some() + { + return Err(IdentityError::ResourceBusy); + } + } + let released = { + let mut table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let mut state = { + let value = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + decode_state_cached(value.value())? + }; + let released = + super::seal_generation_state(&mut state, authorization, mirror, inventory)?; + let bytes = encode_state(&state)?; + table + .insert(ACTIVE_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + released + }; + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(released) + } + + /// Serve consistency evidence between any two retained exact prefixes. + pub fn consistency_proof( + &self, + old_size: u64, + new_size: u64, + ) -> Result { + self.memory_view()?.consistency_proof(old_size, new_size) + } + + fn prepare_append( + &self, + permit: ProviderAppendPermit, + observed_at: Timestamp, + ) -> Result { + let ProviderAppendPermit { admission, request } = permit; + request.validate_for(&admission)?; + let _charged_bytes = request.encoded_bytes(); + admission.validate_observed_at(observed_at)?; + let checkpoint_bundle = admission.checkpoint_bundle().cloned(); + if let Some(bundle) = checkpoint_bundle.as_ref() { + super::interchange::validate_checkpoint_bundle_interchange_item(bundle)?; + } + let portable_accounting = self.lock_portable_accounting()?; + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + { + let prepared = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + if prepared + .get(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .is_some() + { + return Err(IdentityError::ResourceBusy); + } + } + let state = { + let table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let value = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + decode_state_cached(value.value())? + }; + if state.provider != self.provider + || state.log_id != self.log_id + || state.key_version != self.key_version + { + return Err(IdentityError::StorageCorruption); + } + if state + .latest_head + .as_ref() + .is_some_and(|head| observed_at < head.body().observed_at()) + { + return Err(IdentityError::ProviderRollback); + } + let base_tree = state.tree()?; + let mut entries = state.active()?.entries.clone(); + let mut leaf_hashes = state.leaf_hashes.clone(); + let mut checkpoint_bundles = state.active()?.checkpoint_bundles.clone(); + let duplicate_index = entries.iter().position(|entry| { + entry.account_id() == admission.account_id() && entry.subject() == admission.subject() + }); + let duplicate_bundle_merge = if let Some(index) = duplicate_index { + super::merge_duplicate_bundle(&state, index, checkpoint_bundle.as_ref())? + } else if let Some(bundle) = checkpoint_bundle.as_ref() { + super::validate_checkpoint_admission(&state, bundle)?; + None + } else { + None + }; + if let Some((index, merged)) = duplicate_bundle_merge { + super::interchange::validate_checkpoint_bundle_interchange_item(&merged)?; + let retained = checkpoint_bundles + .get_mut(index) + .ok_or(IdentityError::StorageCorruption)?; + *retained = merged; + } + let leaf_index = match duplicate_index { + Some(index) => u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider duplicate index", + })?, + None => { + if entries.len() == MAX_STORED_PROVIDER_ENTRIES + || entries.len() == MAX_MERKLE_LOG_LEAVES + { + return Err(IdentityError::limit( + "stored provider generation entries", + entries.len().saturating_add(1), + MAX_STORED_PROVIDER_ENTRIES, + )); + } + let entry = ProviderLogEntryBody::new( + self.provider.id()?, + self.log_id, + admission.account_id(), + admission.subject(), + observed_at, + Extensions::default(), + )?; + let index = u64::try_from(entries.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "stored provider append index", + } + })?; + leaf_hashes.push(entry.merkle_leaf_hash()?); + entries.push(entry); + if let Some(bundle) = checkpoint_bundle { + checkpoint_bundles.push(bundle); + } + index + } + }; + let material = + GenerationMaterialWire::from_parts(&entries, &leaf_hashes, &checkpoint_bundles)?; + let base_tree_size = base_tree.tree_size()?; + let base_tree_root = base_tree.root()?; + let owner_token = prepared_owner_token( + base_tree_root, + base_tree_size, + &material, + leaf_index, + observed_at, + )?; + let prepared = PreparedAppendWire { + version: STORE_VERSION, + owner_token, + base_tree_size, + base_tree_root, + requested_observed_at: observed_at, + leaf_index, + material, + stage: PreparedAppendStage::Prepared, + }; + self.preflight_prepared_candidate(&prepared, &state, &portable_accounting)?; + let bytes = encode_prepared(&prepared)?; + { + let mut table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .insert(PREPARED_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(prepared) + } + + fn begin_signing( + &self, + prepared: PreparedAppendWire, + ) -> Result { + if !matches!(prepared.stage, PreparedAppendStage::Prepared) { + return Err(IdentityError::ResourceBusy); + } + let body = self.prepared_head_body(&prepared)?; + let signing = PreparedAppendWire { + stage: PreparedAppendStage::Signing { body }, + ..prepared.clone() + }; + self.replace_prepared(&prepared, &signing)?; + Ok(signing) + } + + fn sign_and_persist( + &self, + signing: PreparedAppendWire, + signer: &S, + ) -> Result { + let body = signing.signing_body()?.clone(); + let signature = signer.sign_provider_head(&body.signing_bytes()?)?; + let head = SignedProviderHead::new(body, signature); + self.persist_signed_prepared(signing, head) + } + + fn persist_signed_prepared( + &self, + signing: PreparedAppendWire, + head: SignedProviderHead, + ) -> Result { + let body = signing.signing_body()?; + if head.body() != body { + return Err(IdentityError::InvalidRelationship { + resource: "provider signed candidate body", + }); + } + head.verify(&self.provider)?; + let signed = PreparedAppendWire { + stage: PreparedAppendStage::Signed { head }, + ..signing.clone() + }; + self.replace_prepared(&signing, &signed)?; + Ok(signed) + } + + fn promote_signed_prepared( + &self, + prepared: PreparedAppendWire, + ) -> Result { + let head = prepared.signed_head()?.clone(); + let mut portable_accounting = self.lock_portable_accounting()?; + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let mut state = { + let table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let value = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + decode_state_cached(value.value())? + }; + let durable_prepared = { + let table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let value = table + .get(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::ResourceBusy)?; + decode_prepared(value.value())? + }; + if durable_prepared != prepared { + return Err(IdentityError::ResourceBusy); + } + let next_portable_accounting = + self.validate_prepared_against_state(&prepared, &state, &portable_accounting)?; + let base_tree = state.tree()?; + if base_tree.tree_size()? != prepared.base_tree_size + || base_tree.root()? != prepared.base_tree_root + || head.body().observed_at() != prepared.requested_observed_at + { + return Err(IdentityError::ResourceBusy); + } + let candidate_tree = AppendOnlyMerkleLog::from_leaf_hashes( + prepared.material.leaf_hashes.as_slice().to_vec(), + )?; + if head.body().tree_size() != candidate_tree.tree_size()? + || head.body().tree_root() != candidate_tree.root()? + { + return Err(IdentityError::InvalidProof); + } + let entry_index = usize::try_from(prepared.leaf_index).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "stored provider receipt index", + } + })?; + let entry = prepared + .material + .entries + .as_slice() + .get(entry_index) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + let receipt = InclusionReceipt::new( + entry, + prepared.leaf_index, + candidate_tree + .inclusion_proof(prepared.leaf_index)? + .audit_path() + .to_vec(), + head.clone(), + )?; + receipt.verify(&self.provider)?; + + state.active_mut()?.entries = prepared.material.entries.into_vec(); + state.leaf_hashes = prepared.material.leaf_hashes.into_vec(); + state.active_mut()?.checkpoint_bundles = + decode_checkpoint_bundles(prepared.material.checkpoint_bundles.as_slice())?; + state.active_mut()?.checkpoint_index = prepared + .material + .checkpoint_index + .into_vec() + .iter() + .map(ProviderCheckpointIndexWire::as_index) + .collect(); + state.latest_head = Some(head); + if entry_index < state.active()?.receipts.len() { + state.active_mut()?.receipts[entry_index] = receipt.clone(); + } else if entry_index == state.active()?.receipts.len() { + state.active_mut()?.receipts.push(receipt.clone()); + } else { + return Err(IdentityError::StorageCorruption); + } + state.validate_cached()?; + let bytes = encode_state(&state)?; + { + let mut table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .insert(ACTIVE_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + { + let mut table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let _ = table + .remove(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + *portable_accounting = next_portable_accounting; + Ok(receipt) + } + + fn replace_prepared( + &self, + expected: &PreparedAppendWire, + replacement: &PreparedAppendWire, + ) -> Result<(), IdentityError> { + let portable_accounting = self.lock_portable_accounting()?; + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let state = { + let table = write + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let value = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + decode_state_cached(value.value())? + }; + self.validate_prepared_against_state(expected, &state, &portable_accounting)?; + let matches = { + let table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .get(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| decode_prepared(value.value())) + .transpose()? + .is_some_and(|prepared| prepared == *expected) + }; + if !matches { + return Err(IdentityError::ResourceBusy); + } + let bytes = encode_prepared(replacement)?; + { + let mut table = write + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .insert(PREPARED_KEY, bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + } + write.commit().map_err(|_| IdentityError::StorageCorruption) + } + + fn prepared_head_body( + &self, + prepared: &PreparedAppendWire, + ) -> Result { + let tree = AppendOnlyMerkleLog::from_leaf_hashes( + prepared.material.leaf_hashes.as_slice().to_vec(), + )?; + ProviderHeadBody::new( + self.provider.id()?, + self.log_id, + self.key_version, + tree.tree_size()?, + tree.root()?, + prepared.requested_observed_at, + Extensions::default(), + ) + } + + fn preflight_prepared_candidate( + &self, + prepared: &PreparedAppendWire, + state: &ProviderGenerationState, + base_accounting: &super::interchange::ProviderGenerationPortableAccounting, + ) -> Result { + let active = state.active()?; + let candidate_entries = prepared.material.entries.as_slice(); + let candidate_leaf_hashes = prepared.material.leaf_hashes.as_slice(); + let candidate_bundles = + decode_checkpoint_bundles(prepared.material.checkpoint_bundles.as_slice())?; + let entry_index = usize::try_from(prepared.leaf_index).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "stored provider portable preflight entry index", + } + })?; + let candidate_tree = AppendOnlyMerkleLog::from_leaf_hashes(candidate_leaf_hashes.to_vec())?; + let placeholder_head = SignedProviderHead::new( + self.prepared_head_body(prepared)?, + ProtocolSignature::ed25519([0; 64]), + ); + let candidate_entry = candidate_entries + .get(entry_index) + .cloned() + .ok_or(IdentityError::StorageCorruption)?; + let placeholder_receipt = InclusionReceipt::new( + candidate_entry, + prepared.leaf_index, + candidate_tree + .inclusion_proof(prepared.leaf_index)? + .audit_path() + .to_vec(), + placeholder_head, + )?; + + let mut next = *base_accounting; + if entry_index == active.entries.len() { + let expected_len = + active + .entries + .len() + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider portable candidate entries", + })?; + if candidate_entries.len() != expected_len + || candidate_leaf_hashes.len() != expected_len + || candidate_entries[..entry_index] != active.entries + || candidate_leaf_hashes[..entry_index] != state.leaf_hashes + { + return Err(IdentityError::StorageCorruption); + } + next = next + .with_appended_entry(&candidate_entries[entry_index])? + .with_appended_leaf_hash(&candidate_leaf_hashes[entry_index])? + .with_appended_receipt(&placeholder_receipt)?; + if candidate_bundles != active.checkpoint_bundles { + let expected_bundle_len = active.checkpoint_bundles.len().checked_add(1).ok_or( + IdentityError::ArithmeticOverflow { + resource: "stored provider portable candidate checkpoint bundles", + }, + )?; + if candidate_bundles.len() != expected_bundle_len + || candidate_bundles[..active.checkpoint_bundles.len()] + != active.checkpoint_bundles + { + return Err(IdentityError::StorageCorruption); + } + next = next.with_appended_checkpoint_bundle( + candidate_bundles + .last() + .ok_or(IdentityError::StorageCorruption)?, + )?; + } + return Ok(next); + } + + if entry_index >= active.entries.len() + || candidate_entries != active.entries + || candidate_leaf_hashes != state.leaf_hashes + || candidate_bundles.len() != active.checkpoint_bundles.len() + { + return Err(IdentityError::StorageCorruption); + } + let previous_receipt = active + .receipts + .get(entry_index) + .ok_or(IdentityError::StorageCorruption)?; + next = next.with_replaced_receipt(previous_receipt, &placeholder_receipt)?; + let mut changed_bundle = None; + for (index, (previous, candidate)) in active + .checkpoint_bundles + .iter() + .zip(&candidate_bundles) + .enumerate() + { + if previous != candidate { + if changed_bundle.is_some() { + return Err(IdentityError::StorageCorruption); + } + changed_bundle = Some((index, previous, candidate)); + } + } + if let Some((_index, previous, candidate)) = changed_bundle { + next = next.with_replaced_checkpoint_bundle(previous, candidate)?; + } + Ok(next) + } + + fn validate_prepared_against_state( + &self, + prepared: &PreparedAppendWire, + state: &ProviderGenerationState, + base_accounting: &super::interchange::ProviderGenerationPortableAccounting, + ) -> Result { + if prepared.version != STORE_VERSION + || prepared.owner_token + != prepared_owner_token( + prepared.base_tree_root, + prepared.base_tree_size, + &prepared.material, + prepared.leaf_index, + prepared.requested_observed_at, + )? + { + return Err(IdentityError::StorageCorruption); + } + prepared.material.validate(&self.provider, self.log_id)?; + let base_tree = state.tree()?; + if state.provider != self.provider + || state.log_id != self.log_id + || state.key_version != self.key_version + || base_tree.tree_size()? != prepared.base_tree_size + || base_tree.root()? != prepared.base_tree_root + { + return Err(IdentityError::StorageCorruption); + } + match &prepared.stage { + PreparedAppendStage::Prepared => {} + PreparedAppendStage::Signing { body } => { + if body != &self.prepared_head_body(prepared)? { + return Err(IdentityError::StorageCorruption); + } + } + PreparedAppendStage::Signed { head } => { + if head.body() != &self.prepared_head_body(prepared)? { + return Err(IdentityError::StorageCorruption); + } + head.verify(&self.provider) + .map_err(|_| IdentityError::StorageCorruption)?; + } + } + self.preflight_prepared_candidate(prepared, state, base_accounting) + } + + fn load_prepared(&self) -> Result, IdentityError> { + let read = self + .database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let table = read + .open_table(PREPARED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .get(PREPARED_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| decode_prepared(value.value())) + .transpose() + } + + fn recover_prepared_append(&self) -> Result<(), IdentityError> { + let Some(prepared) = self.load_prepared()? else { + return Ok(()); + }; + let state = self.load_state()?; + if matches!(state.payload, super::ProviderGenerationPayload::Sealed(_)) { + return Err(IdentityError::StorageCorruption); + } + { + let portable_accounting = self.lock_portable_accounting()?; + self.validate_prepared_against_state(&prepared, &state, &portable_accounting)?; + } + match &prepared.stage { + PreparedAppendStage::Prepared | PreparedAppendStage::Signing { .. } => Ok(()), + PreparedAppendStage::Signed { .. } => { + self.promote_signed_prepared(prepared).map(|_| ()) + } + } + } + + fn load_state(&self) -> Result { + let read = self + .database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let table = read + .open_table(COMMITTED_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let value = table + .get(ACTIVE_KEY) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::StorageCorruption)?; + let state = decode_state_cached(value.value())?; + if state.provider != self.provider + || state.log_id != self.log_id + || state.key_version != self.key_version + { + return Err(IdentityError::StorageCorruption); + } + Ok(state) + } + + fn lock_portable_accounting( + &self, + ) -> Result< + MutexGuard<'_, super::interchange::ProviderGenerationPortableAccounting>, + IdentityError, + > { + self.portable_accounting + .lock() + .map_err(|_| IdentityError::StorageCorruption) + } + + fn memory_view(&self) -> Result { + Ok(MemoryProviderStore { + state: Arc::new(Mutex::new(self.load_state()?)), + portable_accounting: Arc::clone(&self.portable_accounting), + }) + } +} + +impl super::AddressedProviderGeneration for RedbProviderStore { + fn generation_route(&self) -> Result { + Self::generation_route(self) + } +} + +fn encode_state(state: &ProviderGenerationState) -> Result, IdentityError> { + encode_bounded(&StoredProviderWire::from_state(state)?) +} + +fn portable_accounting_for_committed_state( + state: &ProviderGenerationState, +) -> Result { + match &state.payload { + ProviderGenerationPayload::Sealed(sealed) if !sealed.archive_complete => { + // A locally sealed generation deliberately has no complete portable export and is + // irreversibly read-only. Mutation paths reject the sealed payload before consulting + // this cache; the zero value is therefore an explicit non-mutable sentinel. + Ok(super::interchange::ProviderGenerationPortableAccounting::empty()) + } + ProviderGenerationPayload::Active(_) | ProviderGenerationPayload::Sealed(_) => { + super::interchange::ProviderGenerationPortableAccounting::from_export(&state.export()?) + } + } +} + +fn decode_state(bytes: &[u8]) -> Result { + decode_bounded::(bytes)?.into_state() +} + +fn decode_state_cached(bytes: &[u8]) -> Result { + decode_bounded::(bytes)?.into_state_cached() +} + +fn encode_prepared(prepared: &PreparedAppendWire) -> Result, IdentityError> { + encode_bounded(prepared) +} + +fn decode_prepared(bytes: &[u8]) -> Result { + let prepared = decode_bounded::(bytes)?; + if prepared.version != STORE_VERSION { + return Err(IdentityError::StorageCorruption); + } + Ok(prepared) +} + +fn encode_bounded(value: &T) -> Result, IdentityError> { + let bytes = encode_wire(value).map_err(|_| IdentityError::StorageCorruption)?; + if bytes.len() > MAX_STORED_PROVIDER_BYTES { + return Err(IdentityError::limit( + "stored provider generation bytes", + bytes.len(), + MAX_STORED_PROVIDER_BYTES, + )); + } + Ok(bytes) +} + +fn decode_bounded(bytes: &[u8]) -> Result +where + T: serde::de::DeserializeOwned + Serialize, +{ + if bytes.len() > MAX_STORED_PROVIDER_BYTES { + return Err(IdentityError::StorageCorruption); + } + decode_wire(bytes).map_err(|_| IdentityError::StorageCorruption) +} + +fn build_account_index( + entries: &[ProviderLogEntryBody], +) -> Result, IdentityError> { + let mut index = BTreeMap::>::new(); + for (position, entry) in entries.iter().enumerate() { + let leaf_index = + u64::try_from(position).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider account index", + })?; + index + .entry(entry.account_id()) + .or_default() + .push(leaf_index); + } + index + .into_iter() + .map(|(account_id, leaf_indices)| { + Ok(AccountIndexWire { + account_id, + leaf_indices: BoundedVec::new( + "stored provider account leaf indices", + leaf_indices, + )?, + }) + }) + .collect() +} + +fn build_frontier(leaf_hashes: &[Digest]) -> Result, IdentityError> { + let mut frontier = Vec::new(); + let mut cursor = 0_usize; + let mut remaining = leaf_hashes.len(); + while remaining != 0 { + let level_u32 = usize::BITS + .checked_sub(remaining.leading_zeros()) + .and_then(|bits| bits.checked_sub(1)) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider frontier level", + })?; + let level = u8::try_from(level_u32).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider frontier level", + })?; + let size = + 1_usize + .checked_shl(u32::from(level)) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider frontier size", + })?; + let end = cursor + .checked_add(size) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider frontier range", + })?; + let root = + AppendOnlyMerkleLog::from_leaf_hashes(leaf_hashes[cursor..end].to_vec())?.root()?; + frontier.push(FrontierNodeWire { level, root }); + cursor = end; + remaining -= size; + } + Ok(frontier) +} + +fn build_nodes(leaf_hashes: &[Digest]) -> Result, IdentityError> { + let mut nodes = Vec::with_capacity(leaf_hashes.len().saturating_mul(2)); + for (index, leaf_hash) in leaf_hashes.iter().copied().enumerate() { + nodes.push(MerkleNodeWire { + start: u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider Merkle node start", + })?, + size: 1, + root: leaf_hash, + }); + let mut size = 1_usize; + let end = index + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider Merkle node range", + })?; + while end % size.saturating_mul(2) == 0 { + size = size + .checked_mul(2) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider Merkle node size", + })?; + let start = end + .checked_sub(size) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "stored provider Merkle node range", + })?; + let root = + AppendOnlyMerkleLog::from_leaf_hashes(leaf_hashes[start..end].to_vec())?.root()?; + nodes.push(MerkleNodeWire { + start: u64::try_from(start).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider Merkle node start", + })?, + size: u64::try_from(size).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "stored provider Merkle node size", + })?, + root, + }); + } + } + if nodes.len() > MAX_STORED_PROVIDER_NODES { + return Err(IdentityError::limit( + "stored provider Merkle nodes", + nodes.len(), + MAX_STORED_PROVIDER_NODES, + )); + } + Ok(nodes) +} + +fn prepared_owner_token( + base_root: Digest, + base_size: u64, + material: &GenerationMaterialWire, + leaf_index: u64, + observed_at: Timestamp, +) -> Result<[u8; 32], IdentityError> { + let preimage = encode_wire(&PreparedOwnerTokenPreimage { + format_version: PREPARED_OWNER_TOKEN_FORMAT_VERSION, + base_root, + base_size, + material, + leaf_index, + observed_at, + }) + .map_err(|_| IdentityError::StorageCorruption)?; + let mut hasher = blake3::Hasher::new(); + hasher.update(PREPARED_OWNER_TOKEN_DOMAIN); + hasher.update(&[0]); + hasher.update(&preimage); + Ok(*hasher.finalize().as_bytes()) +} + +#[cfg(test)] +mod tests { + use krikos_base::SecretKey; + + use super::*; + use crate::{ + CanonicalWire, DurableProviderAuditor, HashAlgorithm, MemoryProviderAuditStore, ProposalId, + ProtocolSignature, ProviderAdmissionRequest, ProviderAppendPermit, ProviderLogAdmission, + ProviderRecoveryExport, SigningPublicKey, + }; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + struct ManifestRangeMirror { + start: u64, + end_exclusive: u64, + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + struct ManifestMirror { + format_version: u16, + provider_id: crate::ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + source_tree_size: u64, + source_tree_root: Digest, + archive_commitment: Digest, + generation_commitment: Digest, + audit_commitment: Digest, + audit_artifact_commitment: Digest, + inventory_commitment: Digest, + retained_evidence_commitment: Digest, + retained_ranges: Vec, + } + + struct Signer(SecretKey); + + impl ProviderHeadSigner for Signer { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } + } + + fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() + } + + fn sealed_guardian_wire() -> StoredProviderWire { + let signer = Signer(SecretKey::from_bytes(&[0x91; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x92); + let store = MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + for (account_fill, proposal_fill, observed_at) in + [(0x93, 0x94, 90_u64), (0x95, 0x96, 91_u64)] + { + let admission = ProviderLogAdmission::guardian_recovery_intent( + typed_id::(account_fill), + typed_id::(proposal_fill), + Timestamp::from_unix_millis(observed_at), + ); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + ProviderAppendPermit { admission, request }, + Timestamp::from_unix_millis(observed_at), + &signer, + ) + .unwrap(); + } + let generation = store.export_generation().unwrap(); + let audit_store = MemoryProviderAuditStore::new(provider, log_id); + let auditor = DurableProviderAuditor::new(audit_store.clone()); + auditor + .observe(generation.latest_head().unwrap().clone(), None) + .unwrap(); + let recovery = + ProviderRecoveryExport::new(generation, audit_store.snapshot().unwrap()).unwrap(); + let inventory = crate::derive_provider_retention_inventory(&recovery).unwrap(); + let authorization = + crate::verify_provider_compaction(&recovery, &recovery, &inventory).unwrap(); + store + .seal_after_verified_mirror(&authorization, &recovery, &inventory) + .unwrap(); + StoredProviderWire::from_state(&store.lock_state().unwrap()).unwrap() + } + + fn active_guardian_wire() -> StoredProviderWire { + let signer = Signer(SecretKey::from_bytes(&[0x97; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x98); + let store = + MemoryProviderStore::new(provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + let observed_at = Timestamp::from_unix_millis(92); + let admission = ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0x99), + typed_id::(0x9a), + observed_at, + ); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + ProviderAppendPermit { admission, request }, + observed_at, + &signer, + ) + .unwrap(); + StoredProviderWire::from_state(&store.lock_state().unwrap()).unwrap() + } + + fn mutate_manifest(wire: &mut StoredProviderWire, mutate: impl FnOnce(&mut ManifestMirror)) { + let ProviderPayloadWire::Sealed(sealed) = &mut wire.payload else { + panic!("sealed test fixture"); + }; + let manifest = sealed.manifest.as_ref().unwrap(); + let mut mirror: ManifestMirror = decode_wire(&encode_wire(manifest).unwrap()).unwrap(); + mutate(&mut mirror); + sealed.manifest = Some( + decode_wire(&encode_wire(&mirror).unwrap()) + .expect("mutated manifest remains structurally decodable"), + ); + } + + fn assert_wire_corruption(wire: StoredProviderWire) { + assert_eq!(wire.into_state(), Err(IdentityError::StorageCorruption)); + } + + #[test] + fn sealed_wire_reopen_recomputes_manifest_inventory_ranges_and_state_kind() { + let wire = sealed_guardian_wire(); + wire.clone().into_state().unwrap(); + let changed = Digest::new(HashAlgorithm::Blake3_256, [0xee; 32]); + + macro_rules! assert_manifest_field_corruption { + ($field:ident) => {{ + let mut corrupt = wire.clone(); + mutate_manifest(&mut corrupt, |manifest| manifest.$field = changed); + assert_wire_corruption(corrupt); + }}; + } + assert_manifest_field_corruption!(archive_commitment); + assert_manifest_field_corruption!(generation_commitment); + assert_manifest_field_corruption!(audit_commitment); + assert_manifest_field_corruption!(audit_artifact_commitment); + assert_manifest_field_corruption!(inventory_commitment); + assert_manifest_field_corruption!(retained_evidence_commitment); + + let mut corrupt_range = wire.clone(); + mutate_manifest(&mut corrupt_range, |manifest| { + manifest.retained_ranges[0].end_exclusive = + manifest.retained_ranges[0].end_exclusive.saturating_sub(1); + }); + assert_wire_corruption(corrupt_range); + + let mut corrupt_inventory = wire.clone(); + let ProviderPayloadWire::Sealed(sealed) = &mut corrupt_inventory.payload else { + panic!("sealed test fixture"); + }; + let inventory = sealed.inventory.as_mut().unwrap(); + let mut items = inventory.items.clone().into_vec(); + items[0].class_code = ProviderRetentionClass::ProviderRotation.code(); + inventory.items = BoundedVec::new("test changed retention inventory", items).unwrap(); + assert_wire_corruption(corrupt_inventory); + + let mut corrupt_kind = wire; + let ProviderPayloadWire::Sealed(sealed) = &mut corrupt_kind.payload else { + panic!("sealed test fixture"); + }; + sealed.archive_complete = true; + assert_wire_corruption(corrupt_kind); + } + + #[test] + fn active_wire_rejects_entry_head_key_receipt_and_truncation_faults() { + let wire = active_guardian_wire(); + wire.clone().into_state().unwrap(); + + let mut corrupt_entry = wire.clone(); + let ProviderPayloadWire::Active { material, .. } = &mut corrupt_entry.payload else { + panic!("active test fixture"); + }; + let mut entries = material.entries.clone().into_vec(); + let entry = &entries[0]; + entries[0] = ProviderLogEntryBody::new( + entry.provider_id(), + entry.log_id(), + entry.account_id(), + entry.subject(), + Timestamp::from_unix_millis(entry.observed_at().as_unix_millis().saturating_add(1)), + Extensions::default(), + ) + .unwrap(); + material.entries = BoundedVec::new("test corrupt provider entry", entries).unwrap(); + assert_wire_corruption(corrupt_entry); + + let mut corrupt_head = wire.clone(); + corrupt_head.latest_head = None; + assert_wire_corruption(corrupt_head); + + let mut corrupt_key = wire.clone(); + corrupt_key.key_version = ProviderKeyVersion::GENESIS.checked_next().unwrap(); + assert_wire_corruption(corrupt_key); + + let mut corrupt_receipt = wire.clone(); + let ProviderPayloadWire::Active { receipts, .. } = &mut corrupt_receipt.payload else { + panic!("active test fixture"); + }; + *receipts = BoundedVec::new("test corrupt provider receipts", Vec::new()).unwrap(); + assert_wire_corruption(corrupt_receipt); + + let bytes = encode_bounded(&wire).unwrap(); + for truncated_length in [0, 1, bytes.len() / 2, bytes.len() - 1] { + assert_eq!( + decode_state(&bytes[..truncated_length]), + Err(IdentityError::StorageCorruption) + ); + } + } + + #[test] + fn material_validation_rejects_each_persisted_derived_component() { + let signer = Signer(SecretKey::from_bytes(&[0xc1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xc2); + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + log_id, + typed_id::(0xc3), + crate::ProviderLogSubject::EventIntent(typed_id::(0xc4)), + Timestamp::from_unix_millis(70), + Extensions::default(), + ) + .unwrap(); + let leaf_hash = entry.merkle_leaf_hash().unwrap(); + let material = GenerationMaterialWire::from_parts(&[entry], &[leaf_hash], &[]).unwrap(); + material.validate(&provider, log_id).unwrap(); + assert_eq!(material.account_index.len(), 1); + assert_eq!(material.frontier.len(), 1); + assert_eq!(material.nodes.len(), 1); + + let mut corrupt_index = material.clone(); + corrupt_index.account_index = + BoundedVec::new("test corrupt account index", Vec::new()).unwrap(); + assert_eq!( + corrupt_index.validate(&provider, log_id), + Err(IdentityError::StorageCorruption) + ); + + let mut corrupt_frontier = material.clone(); + corrupt_frontier.frontier = BoundedVec::new("test corrupt frontier", Vec::new()).unwrap(); + assert_eq!( + corrupt_frontier.validate(&provider, log_id), + Err(IdentityError::StorageCorruption) + ); + + let mut corrupt_nodes = material.clone(); + corrupt_nodes.nodes = BoundedVec::new("test corrupt nodes", Vec::new()).unwrap(); + assert_eq!( + corrupt_nodes.validate(&provider, log_id), + Err(IdentityError::StorageCorruption) + ); + + let mut corrupt_leaf = material; + corrupt_leaf.leaf_hashes = BoundedVec::new( + "test corrupt leaf hash", + vec![Digest::new(HashAlgorithm::Blake3_256, [0xff; 32])], + ) + .unwrap(); + assert_eq!( + corrupt_leaf.validate(&provider, log_id), + Err(IdentityError::StorageCorruption) + ); + } + + #[test] + fn prepared_owner_token_uses_a_versioned_canonical_domain_preimage() { + let material = GenerationMaterialWire::from_parts(&[], &[], &[]).unwrap(); + let base_root = Digest::new(HashAlgorithm::Blake3_256, [0xa5; 32]); + let base_size = 0_u64; + let leaf_index = 0_u64; + let observed_at = Timestamp::from_unix_millis(73); + let canonical_preimage = encode_wire(&( + 1_u16, + base_root, + base_size, + &material, + leaf_index, + observed_at, + )) + .unwrap(); + let mut expected_hasher = blake3::Hasher::new(); + expected_hasher.update(b"KRIKOS-ID/provider-prepared-owner/v1"); + expected_hasher.update(&[0]); + expected_hasher.update(&canonical_preimage); + let expected = *expected_hasher.finalize().as_bytes(); + + let actual = + prepared_owner_token(base_root, base_size, &material, leaf_index, observed_at).unwrap(); + assert_eq!(actual, expected); + + let material_bytes = encode_wire(&material).unwrap(); + let mut legacy_hasher = blake3::Hasher::new(); + legacy_hasher.update(b"KRIKOS-ID/provider-prepared-owner/v2"); + legacy_hasher.update(base_root.as_bytes()); + legacy_hasher.update(&base_size.to_le_bytes()); + legacy_hasher.update(&u64::try_from(material_bytes.len()).unwrap().to_le_bytes()); + legacy_hasher.update(&material_bytes); + legacy_hasher.update(&leaf_index.to_le_bytes()); + legacy_hasher.update(&observed_at.as_unix_millis().to_le_bytes()); + assert_ne!(actual, *legacy_hasher.finalize().as_bytes()); + } + + #[test] + fn transient_store_version_six_is_rejected_explicitly() { + let mut wire = active_guardian_wire(); + wire.version = 6; + assert_wire_corruption(wire); + } + + #[test] + fn transient_prepared_version_six_is_rejected_explicitly() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider-prepared-version.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xaa; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xab); + let observed_at = Timestamp::from_unix_millis(74); + let store = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + let mut prepared = store + .prepare_append( + ProviderAppendPermit { + admission: ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xac), + typed_id::(0xad), + observed_at, + ), + request: ProviderAdmissionRequest::new(128).unwrap(), + }, + observed_at, + ) + .unwrap(); + prepared.version = 6; + assert_eq!( + decode_prepared(&encode_bounded(&prepared).unwrap()), + Err(IdentityError::StorageCorruption) + ); + } + + #[test] + fn reopen_rejects_multi_entry_prepared_candidate_without_partial_commit() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider-prepared-multi-entry.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xae; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xaf); + let observed_at = Timestamp::from_unix_millis(75); + { + let store = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let mut prepared = store + .prepare_append( + ProviderAppendPermit { + admission: ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xb0), + typed_id::(0xb1), + observed_at, + ), + request: ProviderAdmissionRequest::new(128).unwrap(), + }, + observed_at, + ) + .unwrap(); + let mut entries = prepared.material.entries.as_slice().to_vec(); + let second = ProviderLogEntryBody::new( + provider.id().unwrap(), + log_id, + typed_id::(0xb2), + crate::ProviderLogSubject::EventIntent(typed_id::(0xb3)), + observed_at, + Extensions::default(), + ) + .unwrap(); + let mut leaf_hashes = prepared.material.leaf_hashes.as_slice().to_vec(); + leaf_hashes.push(second.merkle_leaf_hash().unwrap()); + entries.push(second); + prepared.material = + GenerationMaterialWire::from_parts(&entries, &leaf_hashes, &[]).unwrap(); + prepared.owner_token = prepared_owner_token( + prepared.base_tree_root, + prepared.base_tree_size, + &prepared.material, + prepared.leaf_index, + prepared.requested_observed_at, + ) + .unwrap(); + let bytes = encode_prepared(&prepared).unwrap(); + let write = store.database.begin_write().unwrap(); + { + let mut table = write.open_table(PREPARED_TABLE).unwrap(); + table.insert(PREPARED_KEY, bytes.as_slice()).unwrap(); + } + write.commit().unwrap(); + assert_eq!(store.snapshot().unwrap().tree_size(), 0); + } + + assert!(matches!( + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS,), + Err(IdentityError::StorageCorruption) + )); + let database = Database::create(&path).unwrap(); + let read = database.begin_read().unwrap(); + let table = read.open_table(COMMITTED_TABLE).unwrap(); + let state = decode_state(table.get(ACTIVE_KEY).unwrap().unwrap().value()).unwrap(); + assert_eq!(state.snapshot().unwrap().tree_size(), 0); + } + + #[test] + fn exact_guardian_observation_time_is_checked_before_redb_prepare() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xb1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xb2); + let admission = ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xb3), + typed_id::(0xb4), + Timestamp::from_unix_millis(60), + ); + let request = ProviderAdmissionRequest::new(128).unwrap(); + { + let store = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let permit = ProviderAppendPermit { + admission: admission.clone(), + request, + }; + assert_eq!( + store.append(permit, Timestamp::from_unix_millis(61), &signer), + Err(IdentityError::InvalidRelationship { + resource: "provider admission observation time", + }) + ); + assert_eq!(store.snapshot().unwrap().tree_size(), 0); + } + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 0); + reopened + .append( + ProviderAppendPermit { admission, request }, + Timestamp::from_unix_millis(60), + &signer, + ) + .unwrap(); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 1); + } + + #[test] + fn reopen_promotes_a_durably_signed_candidate_without_reinvoking_signer() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xd1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xd2); + let observed_at = Timestamp::from_unix_millis(80); + let admission = ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xd3), + typed_id::(0xd4), + observed_at, + ); + { + let store = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let prepared = store + .prepare_append( + ProviderAppendPermit { + admission, + request: ProviderAdmissionRequest::new(128).unwrap(), + }, + observed_at, + ) + .unwrap(); + let signing = store.begin_signing(prepared).unwrap(); + let body = signing.signing_body().unwrap().clone(); + let signature = signer + .sign_provider_head(&body.signing_bytes().unwrap()) + .unwrap(); + store + .persist_signed_prepared(signing, SignedProviderHead::new(body, signature)) + .unwrap(); + } + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 1); + } + + #[test] + fn reopen_retains_unstarted_candidate_until_explicit_cancellation() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xe1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xe2); + let observed_at = Timestamp::from_unix_millis(81); + { + let store = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + store + .prepare_append( + ProviderAppendPermit { + admission: ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xe3), + typed_id::(0xe4), + observed_at, + ), + request: ProviderAdmissionRequest::new(128).unwrap(), + }, + observed_at, + ) + .unwrap(); + super::super::interchange::reset_portable_item_encoding_count(); + } + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!( + super::super::interchange::portable_item_encoding_count(), + 3, + "reopen must rerun the exact prepared entry/leaf/receipt portability preflight" + ); + assert!(matches!( + reopened.load_prepared().unwrap().unwrap().stage, + PreparedAppendStage::Prepared + )); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 0); + reopened.cancel_prepared_append().unwrap(); + assert!(reopened.load_prepared().unwrap().is_none()); + } + + #[test] + fn signing_candidate_survives_signer_failure_and_wrong_signer_retry() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xf1; 32])); + let wrong_signer = Signer(SecretKey::from_bytes(&[0xf2; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xf3); + let observed_at = Timestamp::from_unix_millis(82); + let expected_body = { + let store = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let prepared = store + .prepare_append( + ProviderAppendPermit { + admission: ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xf4), + typed_id::(0xf5), + observed_at, + ), + request: ProviderAdmissionRequest::new(128).unwrap(), + }, + observed_at, + ) + .unwrap(); + store + .begin_signing(prepared) + .unwrap() + .signing_body() + .unwrap() + .clone() + }; + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert!(reopened.resume_append(&wrong_signer).is_err()); + let retained = reopened.load_prepared().unwrap().unwrap(); + assert_eq!(retained.signing_body().unwrap(), &expected_body); + assert!(matches!( + retained.stage, + PreparedAppendStage::Signing { .. } + )); + assert_eq!(reopened.resume_append(&signer).unwrap().leaf_index(), 0); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 1); + } + + #[test] + fn signer_return_before_signed_persistence_recovers_from_bound_signing_candidate() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xa1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xa2); + let observed_at = Timestamp::from_unix_millis(83); + { + let store = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let prepared = store + .prepare_append( + ProviderAppendPermit { + admission: ProviderLogAdmission::guardian_recovery_intent( + typed_id::(0xa3), + typed_id::(0xa4), + observed_at, + ), + request: ProviderAdmissionRequest::new(128).unwrap(), + }, + observed_at, + ) + .unwrap(); + let signing = store.begin_signing(prepared).unwrap(); + let _externally_obtained_but_unpublished_signature = signer + .sign_provider_head(&signing.signing_body().unwrap().signing_bytes().unwrap()) + .unwrap(); + // Simulate process death after the signer returned but before the signed candidate + // transaction. The durable Signing record binds the only valid retry body. + } + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!(reopened.resume_append(&signer).unwrap().leaf_index(), 0); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 1); + } +} diff --git a/protocols/krikos-identity/src/publication.rs b/protocols/krikos-identity/src/publication.rs new file mode 100644 index 00000000000..fd61682188f --- /dev/null +++ b/protocols/krikos-identity/src/publication.rs @@ -0,0 +1,659 @@ +//! Monotonic local publication-state tracking for provider-replicated checkpoints. + +use std::task::Poll; + +use crate::{ + AccountId, CheckpointId, IdentityError, InclusionReceipt, ProviderCheckpointBundle, + ProviderDescriptor, ProviderId, ProviderLogId, ProviderLogSubject, ProviderMode, + ProviderPolicy, ProviderPolicyId, ProviderQuorum, StoreFuture, VerifiedCheckpoint, + limits::{ + MAX_HISTORY_PAGE_EVENTS, MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES, MAX_TRANSPARENCY_PROVIDERS, + }, + merkle::MerkleConsistencyProof, + verify_provider_head_progression, +}; + +/// Provider-served checkpoint authority material paired with its authenticated log inclusion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublishedCheckpoint { + bundle: ProviderCheckpointBundle, + receipt: InclusionReceipt, +} + +impl PublishedCheckpoint { + /// Verify a provider receipt and bind it to the exact retained lineage checkpoint leaf. + pub fn new( + bundle: ProviderCheckpointBundle, + receipt: InclusionReceipt, + provider: &ProviderDescriptor, + ) -> Result { + let checkpoint = Self { bundle, receipt }; + checkpoint.validate_for_provider(provider)?; + Ok(checkpoint) + } + + fn validate_for_provider(&self, provider: &ProviderDescriptor) -> Result<(), IdentityError> { + let checkpoint = self.bundle.verified_checkpoint(); + let body = checkpoint.checkpoint().body(); + if self.receipt.entry().account_id() != body.account_id() + || self.receipt.entry().subject() + != ProviderLogSubject::Checkpoint(checkpoint.checkpoint_id()) + { + return Err(IdentityError::InvalidRelationship { + resource: "published checkpoint receipt subject", + }); + } + self.receipt.verify(provider) + } + + /// Complete bounded authority lineage needed to replay and verify the checkpoint. + pub const fn bundle(&self) -> &ProviderCheckpointBundle { + &self.bundle + } + + /// Authenticated provider-log inclusion for the exact bundled checkpoint ID. + pub const fn receipt(&self) -> &InclusionReceipt { + &self.receipt + } +} + +pub(crate) fn encoded_published_checkpoint_bytes( + checkpoint: &PublishedCheckpoint, +) -> Result { + let bundle = checkpoint.bundle(); + let verified = bundle.verified_checkpoint(); + Ok(crate::codec::encode_wire(&( + bundle.genesis(), + bundle.prior_checkpoint_id(), + bundle.events(), + verified.checkpoint(), + verified.transition_event(), + checkpoint.receipt(), + ))? + .len()) +} + +/// One bounded target-to-genesis page of authenticated provider-retained checkpoint lineage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCheckpointLineagePage { + checkpoints: Vec, + next_prior_checkpoint_id: Option, +} + +impl ProviderCheckpointLineagePage { + /// Construct and authenticate one exact retained lineage page. + pub fn new( + account_id: AccountId, + start_checkpoint_id: CheckpointId, + checkpoints: Vec, + next_prior_checkpoint_id: Option, + provider: &ProviderDescriptor, + log_id: ProviderLogId, + ) -> Result { + if checkpoints.len() > MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "provider checkpoint lineage records", + checkpoints.len(), + MAX_HISTORY_PAGE_EVENTS, + )); + } + let first = checkpoints + .first() + .ok_or(IdentityError::InvalidRelationship { + resource: "provider checkpoint lineage page", + })?; + if first.bundle().verified_checkpoint().checkpoint_id() != start_checkpoint_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider checkpoint lineage start", + }); + } + let mut seen = std::collections::BTreeSet::new(); + let mut encoded_bytes = 0_usize; + for checkpoint in &checkpoints { + let bundle = checkpoint.bundle(); + let verified = bundle.verified_checkpoint(); + let body = verified.checkpoint().body(); + if body.account_id() != account_id + || checkpoint.receipt().entry().account_id() != account_id + || checkpoint.receipt().signed_head().body().log_id() != log_id + || !seen.insert(verified.checkpoint_id()) + { + return Err(IdentityError::InvalidProof); + } + checkpoint.validate_for_provider(provider)?; + encoded_bytes = encoded_bytes + .checked_add(encoded_published_checkpoint_bytes(checkpoint)?) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider checkpoint lineage bytes", + })?; + if encoded_bytes > MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES { + return Err(IdentityError::limit( + "provider checkpoint lineage bytes", + encoded_bytes, + MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES, + )); + } + } + for pair in checkpoints.windows(2) { + if pair[0].bundle().prior_checkpoint_id() + != Some(pair[1].bundle().verified_checkpoint().checkpoint_id()) + { + return Err(IdentityError::InvalidProof); + } + } + if checkpoints + .last() + .and_then(|checkpoint| checkpoint.bundle().prior_checkpoint_id()) + != next_prior_checkpoint_id + { + return Err(IdentityError::InvalidProof); + } + Ok(Self { + checkpoints, + next_prior_checkpoint_id, + }) + } + + /// Authenticated checkpoint links in target-to-genesis order. + pub fn checkpoints(&self) -> &[PublishedCheckpoint] { + &self.checkpoints + } + + /// Required prior checkpoint for the next page, or `None` at genesis. + pub const fn next_prior_checkpoint_id(&self) -> Option { + self.next_prior_checkpoint_id + } +} + +/// Runtime-independent client for one configured transparency provider. +/// +/// Implementations own transport deadlines and must preserve timeout, rate-limit, and outage as +/// the distinct [`IdentityError`] variants intended for those conditions. +pub trait TransparencyClient: Send + Sync { + /// Provider represented by this client. + fn provider_id(&self) -> ProviderId; + + /// Submit one fully verified checkpoint and its bounded replayable authority lineage. + fn publish_checkpoint<'a>( + &'a self, + checkpoint: &'a ProviderCheckpointBundle, + ) -> StoreFuture<'a, InclusionReceipt>; + + /// Fetch this provider's latest replayable checkpoint and inclusion for an account. + fn latest_checkpoint( + &self, + account_id: AccountId, + ) -> StoreFuture<'_, Option>; + + /// Fetch one exact retained checkpoint branch and its authenticated provider inclusion. + fn fetch_checkpoint_bundle( + &self, + account_id: AccountId, + checkpoint_id: CheckpointId, + ) -> StoreFuture<'_, Option>; + + /// Fetch one bounded target-to-genesis lineage page starting at an exact retained checkpoint. + fn fetch_checkpoint_lineage_page( + &self, + account_id: AccountId, + start_checkpoint_id: CheckpointId, + maximum_records: usize, + maximum_bytes: usize, + ) -> StoreFuture<'_, Option>; + + /// Fetch an exact consistency proof within one explicitly named log generation. + fn consistency_proof( + &self, + log_id: ProviderLogId, + old_size: u64, + new_size: u64, + ) -> StoreFuture<'_, MerkleConsistencyProof>; +} + +/// Result of one configured provider's attempt in a concurrent publication batch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderPublicationOutcome { + provider_id: ProviderId, + result: Result<(), IdentityError>, +} + +impl ProviderPublicationOutcome { + /// Configured provider attempted by this result. + pub const fn provider_id(&self) -> ProviderId { + self.provider_id + } + + /// Success or the exact transport/verification/admission failure class. + pub const fn result(&self) -> &Result<(), IdentityError> { + &self.result + } +} + +/// Complete configured-provider result set for one bounded concurrent publication attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicationBatch { + outcomes: Vec, + stage: PublicationStage, +} + +impl PublicationBatch { + /// Outcomes in canonical configured-provider order, including unavailable missing clients. + pub fn outcomes(&self) -> &[ProviderPublicationOutcome] { + &self.outcomes + } + + /// Monotonic publication stage after applying every valid receipt in this batch. + pub const fn stage(&self) -> PublicationStage { + self.stage + } +} + +/// Externally meaningful checkpoint publication stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PublicationStage { + /// A deterministic checkpoint body exists but has not been authorized. + Draft, + /// Account authorization has been verified. + Authorized, + /// At least one configured provider returned a verified inclusion receipt. + Published, + /// The account policy's sufficient distinct-provider threshold was reached. + Replicated, + /// A sufficient threshold later re-observed the same inclusions under verified heads. + Observed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderPublication { + provider: ProviderDescriptor, + publication: InclusionReceipt, + observation: Option, +} + +/// Idempotent, monotonic publication journal for one exact checkpoint and provider policy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicationTracker { + account_id: AccountId, + checkpoint_id: CheckpointId, + provider_policy_id: ProviderPolicyId, + policy: ProviderPolicy, + providers: Vec, + sufficient_threshold: ProviderQuorum, + preferred_replication: ProviderQuorum, + authorized: bool, + publications: Vec, + journal_publications: Vec, + journal_observations: Vec, +} + +impl PublicationTracker { + /// Create a draft for one exact checkpoint under an authenticated replicated policy. + pub fn new( + account_id: AccountId, + checkpoint_id: CheckpointId, + provider_policy_id: ProviderPolicyId, + policy: &ProviderPolicy, + ) -> Result { + if policy.id()? != provider_policy_id { + return Err(IdentityError::PolicyVersionMismatch); + } + let replicated = match policy.mode() { + ProviderMode::LocalOnly => return Err(IdentityError::FreshnessUnavailable), + ProviderMode::Replicated(replicated) => replicated, + }; + Ok(Self { + account_id, + checkpoint_id, + provider_policy_id, + policy: policy.clone(), + providers: replicated.providers().to_vec(), + sufficient_threshold: replicated.sufficient_threshold(), + preferred_replication: replicated.preferred_replication(), + authorized: false, + publications: Vec::new(), + journal_publications: Vec::new(), + journal_observations: Vec::new(), + }) + } + + /// Exact account being published. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact checkpoint being published. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Authenticated provider-policy revision governing this journal. + pub const fn provider_policy_id(&self) -> ProviderPolicyId { + self.provider_policy_id + } + + /// Exact authenticated provider policy governing every retained receipt and threshold. + pub const fn provider_policy(&self) -> &ProviderPolicy { + &self.policy + } + + /// Exact sorted configured provider descriptors used to authenticate journal receipts. + pub fn configured_providers(&self) -> &[ProviderDescriptor] { + &self.providers + } + + /// Current monotonic externally meaningful stage. + pub fn stage(&self) -> PublicationStage { + let observed = self + .publications + .iter() + .filter(|publication| publication.observation.is_some()) + .count(); + if observed >= usize::from(self.sufficient_threshold.get()) { + return PublicationStage::Observed; + } + if self.publications.len() >= usize::from(self.sufficient_threshold.get()) { + return PublicationStage::Replicated; + } + if !self.publications.is_empty() { + return PublicationStage::Published; + } + if self.authorized { + PublicationStage::Authorized + } else { + PublicationStage::Draft + } + } + + /// Record verification of the exact checkpoint and governing provider policy. + /// + /// Repeating this with the same verified checkpoint is idempotent. Callers cannot advance a + /// draft by merely asserting that some unrelated checkpoint was authorized. + pub fn mark_authorized( + &mut self, + checkpoint: &VerifiedCheckpoint, + ) -> Result<(), IdentityError> { + let body = checkpoint.checkpoint().body(); + if body.account_id() != self.account_id + || checkpoint.checkpoint_id() != self.checkpoint_id + || body.provider_policy_id() != self.provider_policy_id + { + return Err(IdentityError::InvalidRelationship { + resource: "publication checkpoint authorization", + }); + } + self.authorized = true; + Ok(()) + } + + /// Count distinct configured providers with verified publication receipts. + pub const fn published_provider_count(&self) -> usize { + self.publications.len() + } + + /// Exact sorted publication receipts retained for durable audit-journal reconciliation. + pub fn publication_receipts(&self) -> &[InclusionReceipt] { + &self.journal_publications + } + + /// Exact sorted later-observation receipts retained for durable audit-journal reconciliation. + pub fn observation_receipts(&self) -> &[InclusionReceipt] { + &self.journal_observations + } + + /// Whether the policy's preferred (possibly stricter) replication count was reached. + pub fn preferred_replication_reached(&self) -> bool { + self.publications.len() >= usize::from(self.preferred_replication.get()) + } + + /// Record one verified configured-provider inclusion, deduplicated by provider ID. + pub fn record_publication(&mut self, receipt: InclusionReceipt) -> Result<(), IdentityError> { + if !self.authorized { + return Err(IdentityError::InvalidRelationship { + resource: "publication receipt before account authorization", + }); + } + let provider = self.validate_receipt(&receipt)?.clone(); + let provider_id = receipt.provider_id(); + match self.publication_index(provider_id)? { + Ok(index) => { + let retained = &self.publications[index].publication; + if retained == &receipt { + return Ok(()); + } + if retained.entry().log_id() == receipt.entry().log_id() + && retained.signed_head().body().tree_size() + == receipt.signed_head().body().tree_size() + && retained.signed_head().body().tree_root() + != receipt.signed_head().body().tree_root() + { + return Err(IdentityError::ProviderEquivocation); + } + // A retry may append the same checkpoint again. It is valid availability + // evidence but must not create another provider vote or replace the stable + // publication baseline used for later observation. + Ok(()) + } + Err(index) => { + self.journal_publications.insert(index, receipt.clone()); + self.publications.insert( + index, + ProviderPublication { + provider, + publication: receipt, + observation: None, + }, + ); + Ok(()) + } + } + } + + /// Record a later verified head that still includes the exact originally published leaf. + pub fn record_observation( + &mut self, + receipt: InclusionReceipt, + consistency_proof: &MerkleConsistencyProof, + ) -> Result<(), IdentityError> { + let provider = self.validate_receipt(&receipt)?.clone(); + let index = self + .publication_index(receipt.provider_id())? + .map_err(|_| IdentityError::InvalidRelationship { + resource: "checkpoint observation before provider publication", + })?; + let publication = &self.publications[index].publication; + if receipt.entry() != publication.entry() + || receipt.leaf_index() != publication.leaf_index() + { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint observation publication leaf", + }); + } + if receipt.signed_head().body().observed_at() + <= publication.signed_head().body().observed_at() + { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint observation must be later than submission", + }); + } + verify_provider_head_progression( + &provider, + publication.signed_head(), + receipt.signed_head(), + consistency_proof, + )?; + match &self.publications[index].observation { + Some(retained) if retained == &receipt => Ok(()), + Some(retained) + if retained.signed_head().body().observed_at() + >= receipt.signed_head().body().observed_at() => + { + Ok(()) + } + _ => { + self.publications[index].observation = Some(receipt.clone()); + match self + .journal_observations + .binary_search_by_key(&receipt.provider_id(), InclusionReceipt::provider_id) + { + Ok(retained_index) => self.journal_observations[retained_index] = receipt, + Err(insert_index) => self.journal_observations.insert(insert_index, receipt), + } + Ok(()) + } + } + } + + fn validate_receipt( + &self, + receipt: &InclusionReceipt, + ) -> Result<&ProviderDescriptor, IdentityError> { + if receipt.entry().account_id() != self.account_id + || receipt.entry().subject() != ProviderLogSubject::Checkpoint(self.checkpoint_id) + { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint publication receipt subject", + }); + } + let provider = self + .providers + .iter() + .find(|provider| provider.id() == Ok(receipt.provider_id())) + .ok_or(IdentityError::FreshnessUnavailable)?; + receipt.verify(provider)?; + Ok(provider) + } + + fn publication_index( + &self, + provider_id: ProviderId, + ) -> Result, IdentityError> { + let mut low = 0_usize; + let mut high = self.publications.len(); + while low < high { + let middle = low + (high - low) / 2; + let middle_id = self.publications[middle].provider.id()?; + if middle_id < provider_id { + low = middle + 1; + } else { + high = middle; + } + } + if self + .publications + .get(low) + .is_some_and(|publication| publication.provider.id() == Ok(provider_id)) + { + Ok(Ok(low)) + } else { + Ok(Err(low)) + } + } +} + +/// Concurrently publish the exact verified checkpoint to every configured provider client. +/// +/// Client futures are all created before polling begins and are polled as one bounded set. A +/// missing configured client is reported as [`IdentityError::ProviderUnavailable`]. Individual +/// provider failures do not discard valid receipts returned by other providers and cannot advance +/// the tracker beyond the threshold actually verified. +pub async fn publish_checkpoint_concurrently( + tracker: &mut PublicationTracker, + checkpoint: &ProviderCheckpointBundle, + clients: &[&dyn TransparencyClient], +) -> Result { + if clients.len() > MAX_TRANSPARENCY_PROVIDERS { + return Err(IdentityError::limit( + "transparency publication clients", + clients.len(), + MAX_TRANSPARENCY_PROVIDERS, + )); + } + let mut client_ids = clients + .iter() + .map(|client| client.provider_id()) + .collect::>(); + client_ids.sort_unstable(); + if client_ids.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(IdentityError::DuplicateElement { + resource: "transparency publication clients", + }); + } + if client_ids.iter().any(|provider_id| { + !tracker + .providers + .iter() + .any(|provider| provider.id() == Ok(*provider_id)) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "transparency publication configured client", + }); + } + + tracker.mark_authorized(checkpoint.verified_checkpoint())?; + let mut pending = Vec::new(); + let mut results = tracker + .providers + .iter() + .map(|provider| { + Ok::<_, IdentityError>((provider.id()?, Err(IdentityError::ProviderUnavailable))) + }) + .collect::, _>>()?; + for (index, provider) in tracker.providers.iter().enumerate() { + let provider_id = provider.id()?; + if let Some(client) = clients + .iter() + .find(|client| client.provider_id() == provider_id) + { + pending.push((index, client.publish_checkpoint(checkpoint))); + } + } + for (index, result) in join_publications(pending).await { + results[index].1 = result; + } + + let mut outcomes = Vec::with_capacity(results.len()); + for (provider_id, result) in results { + let result = match result { + Ok(receipt) => tracker.record_publication(receipt), + Err(error) => Err(error), + }; + outcomes.push(ProviderPublicationOutcome { + provider_id, + result, + }); + } + Ok(PublicationBatch { + outcomes, + stage: tracker.stage(), + }) +} + +async fn join_publications<'a>( + tasks: Vec<(usize, StoreFuture<'a, InclusionReceipt>)>, +) -> Vec<(usize, Result)> { + let task_count = tasks.len(); + let mut remaining = task_count; + let mut tasks = tasks + .into_iter() + .map(|(index, future)| (index, Some(future))) + .collect::>(); + let mut completed = Vec::with_capacity(task_count); + std::future::poll_fn(move |context| { + for (index, slot) in &mut tasks { + let Some(future) = slot.as_mut() else { + continue; + }; + if let Poll::Ready(result) = future.as_mut().poll(context) { + completed.push((*index, result)); + *slot = None; + remaining -= 1; + } + } + if remaining == 0 { + Poll::Ready(std::mem::take(&mut completed)) + } else { + Poll::Pending + } + }) + .await +} diff --git a/protocols/krikos-identity/src/recovery.rs b/protocols/krikos-identity/src/recovery.rs new file mode 100644 index 00000000000..c44974f9232 --- /dev/null +++ b/protocols/krikos-identity/src/recovery.rs @@ -0,0 +1,2511 @@ +//! Bounded recovery-ceremony and explicit fork-resolution wire schemas. + +use std::{fmt, sync::Arc}; + +use krikos_base::{PublicKey, Signature}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + AccountId, BlindingSecret, CheckpointId, ControlPolicy, ControlPolicyId, ControllerDescriptor, + ControllerId, ControllerWeight, DeviceId, Digest, Epoch, EventId, Extensions, ForkId, + FreshnessEvidence, GenesisAnchor, GuardianGrantId, GuardianSetRoot, IdentityError, ProposalId, + ProtocolSignature, ProtocolVersion, ProviderPolicyId, ProviderQuorum, ProviderReceipts, + RecoveryAuthority, RecoveryId, RecoveryPolicy, RecoveryPolicyId, RecoveryPolicyVersion, + SigningPublicKey, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{ + MAX_ACCOUNT_EVENT_BYTES, MAX_CONTROLLERS, MAX_DEVICES, MAX_FORK_HEADS, + MAX_MERKLE_PROOF_HASHES, MAX_RECOVERY_GUARDIANS, + }, + merkle::{MerkleInclusionProof, MerkleSetKey, MerkleSetLeaf}, + schema::BoundedVec, + types::{HashDomain, hash_bytes}, +}; + +/// Frozen Merkle-set type tag for one non-circular blinded guardian-grant leaf. +pub const GUARDIAN_GRANT_LEAF_TYPE_TAG: u16 = 1; + +const GUARDIAN_APPROVAL_SIGNATURE_DOMAIN: &[u8] = b"KRIKOS-ID/guardian-approval/v1"; +const GUARDIAN_GRANT_LEAF_BODY_CODE: u16 = 1; +const GUARDIAN_GRANT_LEAF_VALUE_CODE: u16 = 2; + +macro_rules! canonical_schema { + ($name:ty, $resource:literal) => { + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + const MAX_ENCODED_BYTES: usize = MAX_ACCOUNT_EVENT_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +fn validate_v1(version: ProtocolVersion) -> Result<(), IdentityError> { + if version != ProtocolVersion::V1 { + return Err(IdentityError::UnsupportedVersion { + version: version.get(), + }); + } + Ok(()) +} + +fn validate_strictly_sorted( + values: &[T], + resource: &'static str, +) -> Result<(), IdentityError> { + for pair in values.windows(2) { + if pair[0] == pair[1] { + return Err(IdentityError::DuplicateElement { resource }); + } + if pair[0] > pair[1] { + return Err(IdentityError::NonCanonical); + } + } + Ok(()) +} + +fn sorted_controller_descriptors( + controllers: Vec, +) -> Result, IdentityError> { + if controllers.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "replacement controllers", + }); + } + if controllers.len() > MAX_CONTROLLERS { + return Err(IdentityError::limit( + "replacement controllers", + controllers.len(), + MAX_CONTROLLERS, + )); + } + + let mut identified = Vec::with_capacity(controllers.len()); + for controller in controllers { + identified.push((controller.id()?, controller)); + } + identified.sort_unstable_by_key(|(controller_id, _)| *controller_id); + for pair in identified.windows(2) { + if pair[0].0 == pair[1].0 { + return Err(IdentityError::DuplicateElement { + resource: "replacement controller identifiers", + }); + } + } + for left in 0..identified.len() { + for right in (left + 1)..identified.len() { + if identified[left].1.signing_key() == identified[right].1.signing_key() { + return Err(IdentityError::DuplicateSigningKey); + } + } + } + Ok(identified + .into_iter() + .map(|(_, controller)| controller) + .collect()) +} + +fn validate_sorted_controller_descriptors( + controllers: &[ControllerDescriptor], +) -> Result<(), IdentityError> { + if controllers.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "replacement controllers", + }); + } + if controllers.len() > MAX_CONTROLLERS { + return Err(IdentityError::limit( + "replacement controllers", + controllers.len(), + MAX_CONTROLLERS, + )); + } + + let mut previous = None; + for controller in controllers { + let controller_id = controller.id()?; + if let Some(previous_id) = previous { + if previous_id == controller_id { + return Err(IdentityError::DuplicateElement { + resource: "replacement controller identifiers", + }); + } + if previous_id > controller_id { + return Err(IdentityError::NonCanonical); + } + } + previous = Some(controller_id); + } + for left in 0..controllers.len() { + for right in (left + 1)..controllers.len() { + if controllers[left].signing_key() == controllers[right].signing_key() { + return Err(IdentityError::DuplicateSigningKey); + } + } + } + Ok(()) +} + +/// Complete authority state that a successful recovery installs atomically. +/// +/// Devices in `retained_devices` remain authorized. Every other active device is +/// revoked by the recovery transition; omission is therefore never interpreted as +/// implicit retention. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecoveryAuthorityPlan { + protocol_version: ProtocolVersion, + account_id: AccountId, + prior_checkpoint_id: CheckpointId, + prior_event_head: EventId, + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + nonce: [u8; 32], + replacement_controllers: BoundedVec, + replacement_control_policy: ControlPolicy, + replacement_recovery_policy: RecoveryPolicy, + retained_devices: BoundedVec, + expires_at: Timestamp, + extensions: Extensions, +} + +impl RecoveryAuthorityPlan { + /// Construct and canonicalize a complete v1 replacement-authority plan. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + protocol_version: ProtocolVersion, + account_id: AccountId, + prior_checkpoint_id: CheckpointId, + prior_event_head: EventId, + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + nonce: [u8; 32], + replacement_controllers: Vec, + replacement_control_policy: ControlPolicy, + replacement_recovery_policy: RecoveryPolicy, + mut retained_devices: Vec, + expires_at: Timestamp, + extensions: Extensions, + ) -> Result { + let replacement_controllers = sorted_controller_descriptors(replacement_controllers)?; + retained_devices.sort_unstable(); + Self::from_sorted( + protocol_version, + account_id, + prior_checkpoint_id, + prior_event_head, + recovery_policy_id, + recovery_policy_version, + nonce, + replacement_controllers, + replacement_control_policy, + replacement_recovery_policy, + retained_devices, + expires_at, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_sorted( + protocol_version: ProtocolVersion, + account_id: AccountId, + prior_checkpoint_id: CheckpointId, + prior_event_head: EventId, + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + nonce: [u8; 32], + replacement_controllers: Vec, + replacement_control_policy: ControlPolicy, + replacement_recovery_policy: RecoveryPolicy, + retained_devices: Vec, + expires_at: Timestamp, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if nonce == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "recovery nonce", + }); + } + if expires_at.as_unix_millis() == 0 { + return Err(IdentityError::ZeroValue { + resource: "recovery expiry", + }); + } + validate_sorted_controller_descriptors(&replacement_controllers)?; + validate_strictly_sorted(&retained_devices, "retained recovery devices")?; + let retained_devices = BoundedVec::new("retained recovery devices", retained_devices)?; + + replacement_control_policy.validate_satisfiable(&replacement_controllers)?; + replacement_recovery_policy.validate_controller_authority(&replacement_controllers)?; + let replacement_version = replacement_recovery_policy.policy_version(); + if replacement_version < recovery_policy_version { + return Err(IdentityError::InvalidRelationship { + resource: "recovery policy version rollback", + }); + } + if replacement_version == recovery_policy_version + && replacement_recovery_policy.id()? != recovery_policy_id + { + return Err(IdentityError::InvalidRelationship { + resource: "same-version replacement recovery policy", + }); + } + extensions.validate_critical(&[])?; + + Ok(Self { + protocol_version, + account_id, + prior_checkpoint_id, + prior_event_head, + recovery_policy_id, + recovery_policy_version, + nonce, + replacement_controllers: BoundedVec::new( + "replacement controllers", + replacement_controllers, + )?, + replacement_control_policy, + replacement_recovery_policy, + retained_devices, + expires_at, + extensions, + }) + } + + /// Account whose authority will be replaced. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Checkpoint against which the recovery was proposed. + pub const fn prior_checkpoint_id(&self) -> CheckpointId { + self.prior_checkpoint_id + } + + /// Exact event head committed by the prior checkpoint. + pub const fn prior_event_head(&self) -> EventId { + self.prior_event_head + } + + /// Recovery policy committed by the prior checkpoint. + pub const fn recovery_policy_id(&self) -> RecoveryPolicyId { + self.recovery_policy_id + } + + /// Monotonic version of the pre-recovery policy. + pub const fn recovery_policy_version(&self) -> RecoveryPolicyVersion { + self.recovery_policy_version + } + + /// Fresh, nonzero proposal nonce. + pub const fn nonce(&self) -> &[u8; 32] { + &self.nonce + } + + /// Controllers installed on successful finalization. + pub fn replacement_controllers(&self) -> &[ControllerDescriptor] { + self.replacement_controllers.as_slice() + } + + /// Control policy installed on successful finalization. + pub const fn replacement_control_policy(&self) -> &ControlPolicy { + &self.replacement_control_policy + } + + /// Recovery policy installed on successful finalization. + pub const fn replacement_recovery_policy(&self) -> &RecoveryPolicy { + &self.replacement_recovery_policy + } + + /// Explicitly retained devices; all omitted active devices are revoked. + pub fn retained_devices(&self) -> &[DeviceId] { + self.retained_devices.as_slice() + } + + /// Latest instant at which this plan may be finalized. + pub const fn expires_at(&self) -> Timestamp { + self.expires_at + } +} + +impl<'de> Deserialize<'de> for RecoveryAuthorityPlan { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + prior_checkpoint_id: CheckpointId, + prior_event_head: EventId, + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + nonce: [u8; 32], + replacement_controllers: BoundedVec, + replacement_control_policy: ControlPolicy, + replacement_recovery_policy: RecoveryPolicy, + retained_devices: BoundedVec, + expires_at: Timestamp, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + Self::from_sorted( + wire.protocol_version, + wire.account_id, + wire.prior_checkpoint_id, + wire.prior_event_head, + wire.recovery_policy_id, + wire.recovery_policy_version, + wire.nonce, + wire.replacement_controllers.into_vec(), + wire.replacement_control_policy, + wire.replacement_recovery_policy, + wire.retained_devices.into_vec(), + wire.expires_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(RecoveryAuthorityPlan, "recovery authority plan bytes"); + +/// Body-only recovery proposal. Its [`RecoveryId`] excludes all later approvals. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecoveryProposal { + protocol_version: ProtocolVersion, + plan: RecoveryAuthorityPlan, + extensions: Extensions, +} + +impl RecoveryProposal { + /// Construct a v1 recovery proposal. + pub fn try_new( + protocol_version: ProtocolVersion, + plan: RecoveryAuthorityPlan, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + plan, + extensions, + }) + } + + /// Complete authority plan committed by the proposal. + pub const fn plan(&self) -> &RecoveryAuthorityPlan { + &self.plan + } + + /// Derive the stable body-only recovery identifier. + pub fn recovery_id(&self) -> Result { + RecoveryId::derive(self) + } +} + +impl<'de> Deserialize<'de> for RecoveryProposal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + plan: RecoveryAuthorityPlan, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::try_new(wire.protocol_version, wire.plan, wire.extensions).map_err(de::Error::custom) + } +} + +canonical_schema!(RecoveryProposal, "recovery proposal bytes"); + +/// Private identity and authority assigned to one explicit recovery guardian. +/// +/// Raw guardian relationships deliberately have no public canonical export: +/// +/// ```compile_fail +/// use krikos_identity::{CanonicalWire, GuardianGrant}; +/// fn require_public_wire() {} +/// require_public_wire::(); +/// ``` +/// +/// ```compile_fail +/// use krikos_identity::GuardianGrant; +/// fn require_clone() {} +/// require_clone::(); +/// ``` +#[derive(PartialEq, Eq)] +pub struct GuardianGrant { + protocol_version: ProtocolVersion, + protected_account_id: AccountId, + recovery_policy_id: RecoveryPolicyId, + guardian_account_id: AccountId, + guardian_signing_key: SigningPublicKey, + weight: ControllerWeight, + valid_from_epoch: Epoch, + expires_at: Option, + extensions: Extensions, +} + +impl fmt::Debug for GuardianGrant { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("GuardianGrant()") + } +} + +impl GuardianGrant { + /// Construct a private guardian grant. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + protocol_version: ProtocolVersion, + protected_account_id: AccountId, + recovery_policy_id: RecoveryPolicyId, + guardian_account_id: AccountId, + guardian_signing_key: SigningPublicKey, + weight: ControllerWeight, + valid_from_epoch: Epoch, + expires_at: Option, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if expires_at.is_some_and(|expiry| expiry.as_unix_millis() == 0) { + return Err(IdentityError::ZeroValue { + resource: "guardian grant expiry", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + protected_account_id, + recovery_policy_id, + guardian_account_id, + guardian_signing_key, + weight, + valid_from_epoch, + expires_at, + extensions, + }) + } + + /// Derive the canonical non-circular blinded leaf committed by a guardian-set root. + /// + /// The current recovery-policy identifier is intentionally excluded: the policy identifier + /// commits the guardian-set root, so including it in the root's leaves would create a circular + /// hash dependency. The protected account, guardian identity and key, weight, validity bounds, + /// extensions, and fresh blinding remain committed. Verification separately requires the + /// opened grant's policy identifier to equal the exact authoritative policy identifier. + pub fn blinded_merkle_leaf( + &self, + blinding: &BlindingSecret, + ) -> Result { + let leaf_body = encode_wire(&( + GUARDIAN_GRANT_LEAF_BODY_CODE, + self.protocol_version, + self.protected_account_id, + self.guardian_account_id, + self.guardian_signing_key, + self.weight, + self.valid_from_epoch, + self.expires_at, + &self.extensions, + blinding.as_bytes(), + ))?; + let leaf_id = hash_bytes(HashDomain::GuardianGrant, &leaf_body); + let value_body = encode_wire(&(GUARDIAN_GRANT_LEAF_VALUE_CODE, leaf_id))?; + let value_hash = hash_bytes(HashDomain::GuardianGrant, &value_body); + Ok(MerkleSetLeaf::new( + MerkleSetKey::new(GUARDIAN_GRANT_LEAF_TYPE_TAG, leaf_id)?, + value_hash, + )) + } + + /// Account protected by this private grant. + pub const fn protected_account_id(&self) -> AccountId { + self.protected_account_id + } + + /// Recovery policy to whose hidden guardian set this grant belongs. + pub const fn recovery_policy_id(&self) -> RecoveryPolicyId { + self.recovery_policy_id + } + + /// Guardian account revealed only when this grant is opened. + pub const fn guardian_account_id(&self) -> AccountId { + self.guardian_account_id + } + + /// Signing key authorized by the private grant. + pub const fn guardian_signing_key(&self) -> SigningPublicKey { + self.guardian_signing_key + } + + /// Nonzero guardian weight. + pub const fn weight(&self) -> ControllerWeight { + self.weight + } + + /// First account epoch at which this grant may approve recovery. + pub const fn valid_from_epoch(&self) -> Epoch { + self.valid_from_epoch + } + + /// Optional exclusive expiry instant. + pub const fn expires_at(&self) -> Option { + self.expires_at + } +} + +#[derive(Serialize)] +struct GuardianGrantWireRef<'a> { + protocol_version: ProtocolVersion, + protected_account_id: AccountId, + recovery_policy_id: RecoveryPolicyId, + guardian_account_id: AccountId, + guardian_signing_key: SigningPublicKey, + weight: ControllerWeight, + valid_from_epoch: Epoch, + expires_at: Option, + extensions: &'a Extensions, +} + +impl<'a> From<&'a GuardianGrant> for GuardianGrantWireRef<'a> { + fn from(grant: &'a GuardianGrant) -> Self { + Self { + protocol_version: grant.protocol_version, + protected_account_id: grant.protected_account_id, + recovery_policy_id: grant.recovery_policy_id, + guardian_account_id: grant.guardian_account_id, + guardian_signing_key: grant.guardian_signing_key, + weight: grant.weight, + valid_from_epoch: grant.valid_from_epoch, + expires_at: grant.expires_at, + extensions: &grant.extensions, + } + } +} + +#[derive(Deserialize)] +struct GuardianGrantWire { + protocol_version: ProtocolVersion, + protected_account_id: AccountId, + recovery_policy_id: RecoveryPolicyId, + guardian_account_id: AccountId, + guardian_signing_key: SigningPublicKey, + weight: ControllerWeight, + valid_from_epoch: Epoch, + expires_at: Option, + extensions: Extensions, +} + +impl GuardianGrantWire { + fn into_grant(self) -> Result { + GuardianGrant::try_new( + self.protocol_version, + self.protected_account_id, + self.recovery_policy_id, + self.guardian_account_id, + self.guardian_signing_key, + self.weight, + self.valid_from_epoch, + self.expires_at, + self.extensions, + ) + } +} + +/// Blinded guardian grant plus bounded membership-opening material. +/// +/// Revealed witness material is owned by a signed approval and cannot be freely cloned: +/// +/// ```compile_fail +/// use krikos_identity::GuardianGrantOpening; +/// fn require_clone() {} +/// require_clone::(); +/// ``` +/// +/// ```compile_fail +/// use krikos_identity::{CanonicalWire, GuardianGrantOpening}; +/// fn require_public_wire() {} +/// require_public_wire::(); +/// ``` +#[derive(PartialEq, Eq)] +pub struct GuardianGrantOpening { + protocol_version: ProtocolVersion, + guardian_grant_id: GuardianGrantId, + grant: GuardianGrant, + blinding: BlindingSecret, + guardian_set_root: GuardianSetRoot, + leaf_index: u16, + audit_path: BoundedVec, + extensions: Extensions, +} + +impl fmt::Debug for GuardianGrantOpening { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("GuardianGrantOpening()") + } +} + +impl GuardianGrantOpening { + /// Construct an opening and derive its blinded grant identifier. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + protocol_version: ProtocolVersion, + grant: GuardianGrant, + blinding: BlindingSecret, + guardian_set_root: GuardianSetRoot, + leaf_index: u16, + audit_path: Vec, + extensions: Extensions, + ) -> Result { + let guardian_grant_id = + Self::derive_grant_id(protocol_version, &grant, blinding.as_bytes())?; + Self::from_wire( + protocol_version, + guardian_grant_id, + grant, + blinding, + guardian_set_root, + leaf_index, + audit_path, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_wire( + protocol_version: ProtocolVersion, + guardian_grant_id: GuardianGrantId, + grant: GuardianGrant, + blinding: BlindingSecret, + guardian_set_root: GuardianSetRoot, + leaf_index: u16, + audit_path: Vec, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if usize::from(leaf_index) >= MAX_RECOVERY_GUARDIANS { + return Err(IdentityError::limit( + "guardian membership leaf index", + usize::from(leaf_index), + MAX_RECOVERY_GUARDIANS - 1, + )); + } + if guardian_grant_id + != Self::derive_grant_id(protocol_version, &grant, blinding.as_bytes())? + { + return Err(IdentityError::InvalidIdentifier { + resource: "guardian grant opening", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + guardian_grant_id, + grant, + blinding, + guardian_set_root, + leaf_index, + audit_path: BoundedVec::new("guardian membership audit path", audit_path)?, + extensions, + }) + } + + fn derive_grant_id( + protocol_version: ProtocolVersion, + grant: &GuardianGrant, + blinding: &[u8; 32], + ) -> Result { + let encoded = encode_wire(&( + protocol_version, + GuardianGrantWireRef::from(grant), + blinding, + ))?; + Ok(GuardianGrantId::from_digest(hash_bytes( + HashDomain::GuardianGrant, + &encoded, + ))) + } + + /// Blinded identifier recomputed from the grant and fresh secret. + pub const fn guardian_grant_id(&self) -> GuardianGrantId { + self.guardian_grant_id + } + + /// Revealed private guardian grant. + pub const fn grant(&self) -> &GuardianGrant { + &self.grant + } + + /// Public aggregate guardian-set root to which this proof is addressed. + pub const fn guardian_set_root(&self) -> GuardianSetRoot { + self.guardian_set_root + } + + /// Bounded leaf position in the committed guardian set. + pub const fn leaf_index(&self) -> u16 { + self.leaf_index + } + + /// Bounded Merkle membership path, verified by the projection layer. + pub fn audit_path(&self) -> &[Digest] { + self.audit_path.as_slice() + } +} + +#[derive(Serialize)] +struct GuardianGrantOpeningWireRef<'a> { + protocol_version: ProtocolVersion, + guardian_grant_id: GuardianGrantId, + grant: GuardianGrantWireRef<'a>, + blinding: &'a [u8; 32], + guardian_set_root: GuardianSetRoot, + leaf_index: u16, + audit_path: &'a BoundedVec, + extensions: &'a Extensions, +} + +impl<'a> From<&'a GuardianGrantOpening> for GuardianGrantOpeningWireRef<'a> { + fn from(opening: &'a GuardianGrantOpening) -> Self { + Self { + protocol_version: opening.protocol_version, + guardian_grant_id: opening.guardian_grant_id, + grant: GuardianGrantWireRef::from(&opening.grant), + blinding: opening.blinding.as_bytes(), + guardian_set_root: opening.guardian_set_root, + leaf_index: opening.leaf_index, + audit_path: &opening.audit_path, + extensions: &opening.extensions, + } + } +} + +#[derive(Deserialize)] +struct GuardianGrantOpeningWire { + protocol_version: ProtocolVersion, + guardian_grant_id: GuardianGrantId, + grant: GuardianGrantWire, + blinding: [u8; 32], + guardian_set_root: GuardianSetRoot, + leaf_index: u16, + audit_path: BoundedVec, + extensions: Extensions, +} + +impl GuardianGrantOpeningWire { + fn into_opening(self) -> Result { + GuardianGrantOpening::from_wire( + self.protocol_version, + self.guardian_grant_id, + self.grant.into_grant()?, + BlindingSecret::try_new(self.blinding)?, + self.guardian_set_root, + self.leaf_index, + self.audit_path.into_vec(), + self.extensions, + ) + } +} + +/// Recovery decision signed by a private guardian. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum GuardianApprovalDecision { + /// Approve beginning the exact recovery proposal. + Begin, + /// Approve canceling the exact pending recovery under the same threshold. + Cancel, +} + +impl GuardianApprovalDecision { + /// Stable v1 decision codepoint. + pub const fn code(self) -> u16 { + match self { + Self::Begin => 1, + Self::Cancel => 2, + } + } +} + +impl Serialize for GuardianApprovalDecision { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for GuardianApprovalDecision { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match u16::deserialize(deserializer)? { + 1 => Ok(Self::Begin), + 2 => Ok(Self::Cancel), + code => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "guardian recovery decision", + code, + })), + } + } +} + +canonical_schema!(GuardianApprovalDecision, "guardian recovery decision bytes"); + +/// Exact body signed by one private recovery guardian. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GuardianApprovalBody { + protocol_version: ProtocolVersion, + protected_account_id: AccountId, + recovery_id: RecoveryId, + decision: GuardianApprovalDecision, + guardian_grant_id: GuardianGrantId, + account_epoch: Epoch, + approved_at: Timestamp, + extensions: Extensions, +} + +impl GuardianApprovalBody { + /// Construct one exact guardian decision body. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + protocol_version: ProtocolVersion, + protected_account_id: AccountId, + recovery_id: RecoveryId, + decision: GuardianApprovalDecision, + guardian_grant_id: GuardianGrantId, + account_epoch: Epoch, + approved_at: Timestamp, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if approved_at.as_unix_millis() == 0 { + return Err(IdentityError::ZeroValue { + resource: "guardian approval time", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + protected_account_id, + recovery_id, + decision, + guardian_grant_id, + account_epoch, + approved_at, + extensions, + }) + } + + /// Build the exact domain-separated bytes signed by the private guardian key. + pub fn signing_bytes(&self) -> Result, IdentityError> { + let body = encode_wire(self)?; + let capacity = GUARDIAN_APPROVAL_SIGNATURE_DOMAIN + .len() + .checked_add(1) + .and_then(|length| length.checked_add(body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "guardian approval signing message bytes", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(GUARDIAN_APPROVAL_SIGNATURE_DOMAIN); + message.push(0); + message.extend_from_slice(&body); + Ok(message) + } + + /// Account protected by this signed decision. + pub const fn protected_account_id(&self) -> AccountId { + self.protected_account_id + } + + /// Exact proposal or pending recovery being decided. + pub const fn recovery_id(&self) -> RecoveryId { + self.recovery_id + } + + /// Begin or cancellation decision. + pub const fn decision(&self) -> GuardianApprovalDecision { + self.decision + } + + /// Blinded grant used for this decision. + pub const fn guardian_grant_id(&self) -> GuardianGrantId { + self.guardian_grant_id + } + + /// Account epoch against which grant validity is checked. + pub const fn account_epoch(&self) -> Epoch { + self.account_epoch + } + + /// Explicit signing time used only for grant validity bounds. + pub const fn approved_at(&self) -> Timestamp { + self.approved_at + } +} + +impl<'de> Deserialize<'de> for GuardianApprovalBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + protected_account_id: AccountId, + recovery_id: RecoveryId, + decision: GuardianApprovalDecision, + guardian_grant_id: GuardianGrantId, + account_epoch: Epoch, + approved_at: Timestamp, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::try_new( + wire.protocol_version, + wire.protected_account_id, + wire.recovery_id, + wire.decision, + wire.guardian_grant_id, + wire.account_epoch, + wire.approved_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(GuardianApprovalBody, "guardian approval body bytes"); + +/// One signed guardian decision paired with the private grant opening. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedGuardianApproval { + body: GuardianApprovalBody, + opening: Arc, + signature: ProtocolSignature, +} + +impl SignedGuardianApproval { + /// Attach a signature after validating the opened grant's structural bounds. + pub fn try_new( + body: GuardianApprovalBody, + opening: GuardianGrantOpening, + signature: ProtocolSignature, + ) -> Result { + let grant = opening.grant(); + if body.guardian_grant_id() != opening.guardian_grant_id() { + return Err(IdentityError::InvalidIdentifier { + resource: "guardian approval grant", + }); + } + if body.protected_account_id() != grant.protected_account_id() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approval protected account", + }); + } + if body.account_epoch() < grant.valid_from_epoch() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian grant start epoch", + }); + } + if grant + .expires_at() + .is_some_and(|expiry| body.approved_at() >= expiry) + { + return Err(IdentityError::InvalidRelationship { + resource: "expired guardian grant", + }); + } + Ok(Self { + body, + opening: Arc::new(opening), + signature, + }) + } + + /// Signed guardian approval body. + pub const fn body(&self) -> &GuardianApprovalBody { + &self.body + } + + /// Private grant opening carried with this approval. + pub fn opening(&self) -> &GuardianGrantOpening { + self.opening.as_ref() + } + + /// Guardian signature bytes. + pub const fn signature(&self) -> ProtocolSignature { + self.signature + } + + /// Replace only the signature while sharing the already revealed, validated witness. + /// + /// This supports independently produced signature candidates without duplicating the raw + /// guardian grant, opening, or blinding in memory. + pub fn with_signature(&self, signature: ProtocolSignature) -> Self { + Self { + body: self.body.clone(), + opening: Arc::clone(&self.opening), + signature, + } + } +} + +impl Serialize for SignedGuardianApproval { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + struct Wire<'a> { + body: &'a GuardianApprovalBody, + opening: GuardianGrantOpeningWireRef<'a>, + signature: ProtocolSignature, + } + + Wire { + body: &self.body, + opening: GuardianGrantOpeningWireRef::from(self.opening()), + signature: self.signature, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SignedGuardianApproval { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + body: GuardianApprovalBody, + opening: GuardianGrantOpeningWire, + signature: ProtocolSignature, + } + let wire = Wire::deserialize(deserializer)?; + Self::try_new( + wire.body, + wire.opening.into_opening().map_err(de::Error::custom)?, + wire.signature, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(SignedGuardianApproval, "signed guardian approval bytes"); + +/// Bounded, mergeable decisions from distinct private guardians. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GuardianApprovalSet(BoundedVec); + +impl GuardianApprovalSet { + /// Sort and construct approvals from distinct guardian grants. + pub fn try_new(mut approvals: Vec) -> Result { + approvals.sort_unstable_by_key(|approval| approval.body().guardian_grant_id()); + Self::from_sorted(approvals) + } + + fn from_sorted(approvals: Vec) -> Result { + if approvals.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "guardian recovery approvals", + }); + } + let approvals = BoundedVec::new("guardian recovery approvals", approvals)?; + let values = approvals.as_slice(); + for pair in values.windows(2) { + let left = pair[0].body().guardian_grant_id(); + let right = pair[1].body().guardian_grant_id(); + if left == right { + return Err(IdentityError::DuplicateElement { + resource: "guardian recovery approvals", + }); + } + if left > right { + return Err(IdentityError::NonCanonical); + } + } + + let first = &values[0]; + for approval in &values[1..] { + if approval.body().protected_account_id() != first.body().protected_account_id() + || approval.body().recovery_id() != first.body().recovery_id() + || approval.body().decision() != first.body().decision() + || approval.opening().guardian_set_root() != first.opening().guardian_set_root() + || approval.opening().grant().recovery_policy_id() + != first.opening().grant().recovery_policy_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approval set subject", + }); + } + } + for left in 0..values.len() { + for right in (left + 1)..values.len() { + let left_grant = values[left].opening().grant(); + let right_grant = values[right].opening().grant(); + if left_grant.guardian_account_id() == right_grant.guardian_account_id() + || left_grant.guardian_signing_key() == right_grant.guardian_signing_key() + || values[left].opening().leaf_index() == values[right].opening().leaf_index() + { + return Err(IdentityError::DuplicateElement { + resource: "guardian authority", + }); + } + } + } + Ok(Self(approvals)) + } + + /// Merge two compatible partial approval sets idempotently. + pub fn merge(&self, other: &Self) -> Result { + if self.recovery_id() != other.recovery_id() + || self.decision() != other.decision() + || self.protected_account_id() != other.protected_account_id() + || self.guardian_set_root() != other.guardian_set_root() + || self.recovery_policy_id() != other.recovery_policy_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approval merge subject", + }); + } + + let mut merged = self.as_slice().to_vec(); + for approval in other.as_slice() { + let grant_id = approval.body().guardian_grant_id(); + match merged + .iter() + .find(|candidate| candidate.body().guardian_grant_id() == grant_id) + { + Some(existing) if existing == approval => {} + Some(_) => { + return Err(IdentityError::InvalidRelationship { + resource: "conflicting guardian approval", + }); + } + None => { + if merged.len() == MAX_RECOVERY_GUARDIANS { + return Err(IdentityError::limit( + "guardian recovery approvals", + merged.len() + 1, + MAX_RECOVERY_GUARDIANS, + )); + } + merged.push(approval.clone()); + } + } + } + Self::try_new(merged) + } + + /// Canonically sorted signed approvals. + pub fn as_slice(&self) -> &[SignedGuardianApproval] { + self.0.as_slice() + } + + /// Account protected by every approval. + pub fn protected_account_id(&self) -> AccountId { + self.0.as_slice()[0].body().protected_account_id() + } + + /// Recovery proposal or pending attempt shared by every approval. + pub fn recovery_id(&self) -> RecoveryId { + self.0.as_slice()[0].body().recovery_id() + } + + /// Shared begin or cancel decision. + pub fn decision(&self) -> GuardianApprovalDecision { + self.0.as_slice()[0].body().decision() + } + + /// Public guardian-set root addressed by every opening. + pub fn guardian_set_root(&self) -> GuardianSetRoot { + self.0.as_slice()[0].opening().guardian_set_root() + } + + /// Recovery policy shared by every private grant. + pub fn recovery_policy_id(&self) -> RecoveryPolicyId { + self.0.as_slice()[0].opening().grant().recovery_policy_id() + } + + /// Checked aggregate weight of distinct opened grants. + pub fn total_weight(&self) -> Result { + let mut total = 0_u64; + for approval in self.as_slice() { + total = total + .checked_add(u64::from(approval.opening().grant().weight().get())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "guardian approval weight", + })?; + } + Ok(total) + } + + /// Check aggregate root/count/weight against one authoritative recovery policy. + pub fn validate_threshold(&self, policy: &RecoveryPolicy) -> Result<(), IdentityError> { + if policy.id()? != self.recovery_policy_id() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approval recovery policy", + }); + } + let RecoveryAuthority::GuardianThreshold(threshold) = policy.authority() else { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approvals for controller recovery policy", + }); + }; + if threshold.guardian_set_root() != self.guardian_set_root() + || self.as_slice().len() > usize::from(threshold.guardian_count()) + || self.total_weight()? < u64::from(threshold.required_weight().get()) + { + return Err(IdentityError::UnsatisfiableThreshold); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for GuardianApprovalSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let approvals = BoundedVec::::deserialize( + deserializer, + )?; + Self::from_sorted(approvals.into_vec()).map_err(de::Error::custom) + } +} + +canonical_schema!(GuardianApprovalSet, "guardian approval set bytes"); + +/// Exact immutable facts against which private guardian approvals are verified. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GuardianAuthorityContext { + protected_account_id: AccountId, + recovery_id: RecoveryId, + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + account_epoch: Epoch, + decision: GuardianApprovalDecision, + authority_time: Timestamp, +} + +impl GuardianAuthorityContext { + /// Construct an exact pre-recovery authority context using authenticated explicit time. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + protected_account_id: AccountId, + recovery_id: RecoveryId, + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + account_epoch: Epoch, + decision: GuardianApprovalDecision, + authority_time: Timestamp, + ) -> Result { + if authority_time.as_unix_millis() == 0 { + return Err(IdentityError::ZeroValue { + resource: "guardian authority time", + }); + } + Ok(Self { + protected_account_id, + recovery_id, + recovery_policy_id, + recovery_policy_version, + account_epoch, + decision, + authority_time, + }) + } + + /// Protected account named by every approval and grant. + pub const fn protected_account_id(self) -> AccountId { + self.protected_account_id + } + + /// Exact complete recovery proposal or pending recovery being decided. + pub const fn recovery_id(self) -> RecoveryId { + self.recovery_id + } + + /// Exact authoritative pre-recovery policy identifier. + pub const fn recovery_policy_id(self) -> RecoveryPolicyId { + self.recovery_policy_id + } + + /// Exact authoritative pre-recovery policy version. + pub const fn recovery_policy_version(self) -> RecoveryPolicyVersion { + self.recovery_policy_version + } + + /// Exact pre-recovery account epoch. + pub const fn account_epoch(self) -> Epoch { + self.account_epoch + } + + /// Begin or Cancel decision required from every guardian. + pub const fn decision(self) -> GuardianApprovalDecision { + self.decision + } + + /// Authenticated time at which the approval set is evaluated. + pub const fn authority_time(self) -> Timestamp { + self.authority_time + } +} + +/// Unforgeable result proving that exact private guardian authority met its threshold. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedGuardianAuthority { + context: GuardianAuthorityContext, + guardian_set_root: GuardianSetRoot, + approval_count: u16, + total_weight: u64, +} + +impl VerifiedGuardianAuthority { + /// Exact recovery proposal or pending recovery authorized by the guardians. + pub const fn recovery_id(&self) -> RecoveryId { + self.context.recovery_id + } + + /// Exact pre-recovery policy whose private grant set was proven. + pub const fn recovery_policy_id(&self) -> RecoveryPolicyId { + self.context.recovery_policy_id + } + + /// Exact committed guardian-set root verified for every approval. + pub const fn guardian_set_root(&self) -> GuardianSetRoot { + self.guardian_set_root + } + + /// Number of distinct verified guardian grants counted once. + pub const fn approval_count(&self) -> u16 { + self.approval_count + } + + /// Checked aggregate weight of the distinct verified grants. + pub const fn total_weight(&self) -> u64 { + self.total_weight + } +} + +/// Verify exact private guardian membership, validity, signatures, and threshold authority. +pub fn verify_guardian_authority( + policy: &RecoveryPolicy, + approvals: &GuardianApprovalSet, + context: &GuardianAuthorityContext, +) -> Result { + if policy.id()? != context.recovery_policy_id + || policy.policy_version() != context.recovery_policy_version + { + return Err(IdentityError::PolicyVersionMismatch); + } + let RecoveryAuthority::GuardianThreshold(threshold) = policy.authority() else { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approvals for controller recovery policy", + }); + }; + if approvals.protected_account_id() != context.protected_account_id + || approvals.recovery_id() != context.recovery_id + || approvals.decision() != context.decision + || approvals.recovery_policy_id() != context.recovery_policy_id + { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approval authority subject", + }); + } + if approvals.guardian_set_root() != threshold.guardian_set_root() + || approvals.as_slice().len() > usize::from(threshold.guardian_count()) + { + return Err(IdentityError::InvalidProof); + } + + let mut total_weight = 0_u64; + for approval in approvals.as_slice() { + let body = approval.body(); + let opening = approval.opening(); + let grant = opening.grant(); + if body.protected_account_id() != context.protected_account_id + || body.recovery_id() != context.recovery_id + || body.decision() != context.decision + || body.account_epoch() != context.account_epoch + || body.guardian_grant_id() != opening.guardian_grant_id() + || grant.protected_account_id() != context.protected_account_id + || grant.recovery_policy_id() != context.recovery_policy_id + { + return Err(IdentityError::InvalidRelationship { + resource: "guardian approval projected pre-state", + }); + } + if body.approved_at() > context.authority_time + || grant.valid_from_epoch() > context.account_epoch + || grant + .expires_at() + .is_some_and(|expiry| context.authority_time >= expiry) + { + return Err(IdentityError::StaleEvidence); + } + + let proof = MerkleInclusionProof::new( + u64::from(opening.leaf_index()), + u64::from(threshold.guardian_count()), + opening.audit_path().to_vec(), + )?; + let leaf = grant.blinded_merkle_leaf(&opening.blinding)?; + proof.verify(&leaf, *threshold.guardian_set_root().as_digest())?; + + let public_key = PublicKey::from_bytes(grant.guardian_signing_key().as_bytes()) + .map_err(|_| IdentityError::InvalidSignature)?; + let signature_bytes = approval.signature(); + let signature = Signature::try_from(signature_bytes.as_bytes().as_slice()) + .map_err(|_| IdentityError::InvalidSignature)?; + public_key + .verify(&body.signing_bytes()?, &signature) + .map_err(|_| IdentityError::InvalidSignature)?; + + total_weight = total_weight + .checked_add(u64::from(grant.weight().get())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "verified guardian approval weight", + })?; + } + if total_weight < u64::from(threshold.required_weight().get()) { + return Err(IdentityError::UnsatisfiableThreshold); + } + if total_weight > threshold.total_weight() { + return Err(IdentityError::InvalidProof); + } + let approval_count = u16::try_from(approvals.as_slice().len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "verified guardian approval count", + } + })?; + Ok(VerifiedGuardianAuthority { + context: *context, + guardian_set_root: threshold.guardian_set_root(), + approval_count, + total_weight, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecoveryThresholdEvidenceKind { + ControllerPolicy, + GuardianApprovals(GuardianApprovalSet), +} + +/// Recovery-policy threshold evidence, kept non-circular with account event approval. +/// +/// Controller-policy evidence is completed by the containing event's outer controller +/// approvals. Guardian-policy evidence carries mergeable private guardian approvals. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryThresholdEvidence { + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + kind: RecoveryThresholdEvidenceKind, +} + +impl RecoveryThresholdEvidence { + /// Name a controller-threshold policy evaluated against outer event approvals. + pub const fn controller_policy( + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + ) -> Self { + Self { + recovery_policy_id, + recovery_policy_version, + kind: RecoveryThresholdEvidenceKind::ControllerPolicy, + } + } + + /// Attach mergeable approvals from a private guardian threshold. + pub fn guardian_approvals( + recovery_policy_id: RecoveryPolicyId, + recovery_policy_version: RecoveryPolicyVersion, + approvals: GuardianApprovalSet, + ) -> Result { + if approvals.recovery_policy_id() != recovery_policy_id { + return Err(IdentityError::InvalidRelationship { + resource: "guardian evidence recovery policy", + }); + } + Ok(Self { + recovery_policy_id, + recovery_policy_version, + kind: RecoveryThresholdEvidenceKind::GuardianApprovals(approvals), + }) + } + + /// Exact pre-recovery policy identifier. + pub const fn recovery_policy_id(&self) -> RecoveryPolicyId { + self.recovery_policy_id + } + + /// Exact pre-recovery policy version. + pub const fn recovery_policy_version(&self) -> RecoveryPolicyVersion { + self.recovery_policy_version + } + + /// Guardian approvals when the policy uses private guardians. + pub const fn as_guardian_approvals(&self) -> Option<&GuardianApprovalSet> { + match &self.kind { + RecoveryThresholdEvidenceKind::ControllerPolicy => None, + RecoveryThresholdEvidenceKind::GuardianApprovals(approvals) => Some(approvals), + } + } +} + +impl Serialize for RecoveryThresholdEvidence { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match &self.kind { + RecoveryThresholdEvidenceKind::ControllerPolicy => ( + 1_u16, + (self.recovery_policy_id, self.recovery_policy_version), + ) + .serialize(serializer), + RecoveryThresholdEvidenceKind::GuardianApprovals(approvals) => ( + 2_u16, + ( + self.recovery_policy_id, + self.recovery_policy_version, + approvals, + ), + ) + .serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for RecoveryThresholdEvidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + impl<'de> de::Visitor<'de> for Visitor { + type Value = RecoveryThresholdEvidence; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("v1 recovery threshold evidence") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => { + let (policy_id, policy_version) = sequence + .next_element::<(RecoveryPolicyId, RecoveryPolicyVersion)>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + Ok(RecoveryThresholdEvidence::controller_policy( + policy_id, + policy_version, + )) + } + 2 => { + let (policy_id, policy_version, approvals) = sequence + .next_element::<( + RecoveryPolicyId, + RecoveryPolicyVersion, + GuardianApprovalSet, + )>()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + RecoveryThresholdEvidence::guardian_approvals( + policy_id, + policy_version, + approvals, + ) + .map_err(de::Error::custom) + } + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "recovery threshold evidence", + code: unsupported, + })), + } + } + } + deserializer.deserialize_tuple(2, Visitor) + } +} + +canonical_schema!( + RecoveryThresholdEvidence, + "recovery threshold evidence bytes" +); + +/// Start one authoritative recovery only if the durable recovery slot is vacant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BeginRecovery { + protocol_version: ProtocolVersion, + expected_pending_recovery: Option, + recovery_id: RecoveryId, + proposal: RecoveryProposal, + threshold_evidence: RecoveryThresholdEvidence, + extensions: Extensions, +} + +impl BeginRecovery { + /// Construct a begin transition with an explicit vacant-slot precondition. + pub fn try_new( + protocol_version: ProtocolVersion, + proposal: RecoveryProposal, + threshold_evidence: RecoveryThresholdEvidence, + extensions: Extensions, + ) -> Result { + let recovery_id = proposal.recovery_id()?; + Self::from_wire( + protocol_version, + None, + recovery_id, + proposal, + threshold_evidence, + extensions, + ) + } + + fn from_wire( + protocol_version: ProtocolVersion, + expected_pending_recovery: Option, + recovery_id: RecoveryId, + proposal: RecoveryProposal, + threshold_evidence: RecoveryThresholdEvidence, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if expected_pending_recovery.is_some() { + return Err(IdentityError::InvalidRelationship { + resource: "begin recovery occupied slot", + }); + } + if threshold_evidence.recovery_policy_id() != proposal.plan().recovery_policy_id() + || threshold_evidence.recovery_policy_version() + != proposal.plan().recovery_policy_version() + { + return Err(IdentityError::InvalidRelationship { + resource: "begin recovery policy evidence", + }); + } + if proposal.recovery_id()? != recovery_id { + return Err(IdentityError::InvalidIdentifier { + resource: "begin recovery proposal", + }); + } + if let Some(approvals) = threshold_evidence.as_guardian_approvals() + && (approvals.recovery_id() != recovery_id + || approvals.protected_account_id() != proposal.plan().account_id() + || approvals.decision() != GuardianApprovalDecision::Begin) + { + return Err(IdentityError::InvalidRelationship { + resource: "begin guardian approval subject", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + expected_pending_recovery, + recovery_id, + proposal, + threshold_evidence, + extensions, + }) + } + + /// Whether application requires the single authoritative recovery slot vacant. + pub const fn requires_vacant_recovery_slot(&self) -> bool { + self.expected_pending_recovery.is_none() + } + + /// Stable identifier installed into the pending recovery slot. + pub const fn recovery_id(&self) -> RecoveryId { + self.recovery_id + } + + /// Body-only proposal installed by this transition. + pub const fn proposal(&self) -> &RecoveryProposal { + &self.proposal + } + + /// Threshold evidence evaluated under the exact pre-recovery policy. + pub const fn threshold_evidence(&self) -> &RecoveryThresholdEvidence { + &self.threshold_evidence + } +} + +impl<'de> Deserialize<'de> for BeginRecovery { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + expected_pending_recovery: Option, + recovery_id: RecoveryId, + proposal: RecoveryProposal, + threshold_evidence: RecoveryThresholdEvidence, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::from_wire( + wire.protocol_version, + wire.expected_pending_recovery, + wire.recovery_id, + wire.proposal, + wire.threshold_evidence, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(BeginRecovery, "begin recovery operation bytes"); + +/// Veto an exact pending recovery under the pre-recovery control policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VetoRecovery { + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + pre_recovery_control_policy_id: ControlPolicyId, + freshness: FreshnessEvidence, + extensions: Extensions, +} + +impl VetoRecovery { + /// Construct a control-policy veto for one exact pending recovery. + pub fn try_new( + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + pre_recovery_control_policy_id: ControlPolicyId, + freshness: FreshnessEvidence, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + expected_pending_recovery, + pre_recovery_control_policy_id, + freshness, + extensions, + }) + } + + /// Pending recovery that must exist when this veto is applied. + pub const fn expected_pending_recovery(&self) -> RecoveryId { + self.expected_pending_recovery + } + + /// Pre-recovery control policy used to authorize the outer event approvals. + pub const fn pre_recovery_control_policy_id(&self) -> ControlPolicyId { + self.pre_recovery_control_policy_id + } + + /// Freshness basis required by the pre-recovery veto rule. + pub const fn freshness(&self) -> &FreshnessEvidence { + &self.freshness + } +} + +impl<'de> Deserialize<'de> for VetoRecovery { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + pre_recovery_control_policy_id: ControlPolicyId, + freshness: FreshnessEvidence, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::try_new( + wire.protocol_version, + wire.expected_pending_recovery, + wire.pre_recovery_control_policy_id, + wire.freshness, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(VetoRecovery, "veto recovery operation bytes"); + +/// Cancel an exact pending recovery with fresh evidence under the same recovery policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CancelRecovery { + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + threshold_evidence: RecoveryThresholdEvidence, + freshness: FreshnessEvidence, + extensions: Extensions, +} + +impl CancelRecovery { + /// Construct a recovery-policy cancellation for one exact pending attempt. + pub fn try_new( + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + threshold_evidence: RecoveryThresholdEvidence, + freshness: FreshnessEvidence, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if let Some(approvals) = threshold_evidence.as_guardian_approvals() + && (approvals.recovery_id() != expected_pending_recovery + || approvals.decision() != GuardianApprovalDecision::Cancel) + { + return Err(IdentityError::InvalidRelationship { + resource: "cancel guardian approval subject", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + expected_pending_recovery, + threshold_evidence, + freshness, + extensions, + }) + } + + /// Pending recovery that must exist when this cancellation is applied. + pub const fn expected_pending_recovery(&self) -> RecoveryId { + self.expected_pending_recovery + } + + /// Same pre-recovery threshold evidence required by the original begin. + pub const fn threshold_evidence(&self) -> &RecoveryThresholdEvidence { + &self.threshold_evidence + } + + /// Freshness basis for cancellation under the original recovery policy. + pub const fn freshness(&self) -> &FreshnessEvidence { + &self.freshness + } +} + +impl<'de> Deserialize<'de> for CancelRecovery { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + threshold_evidence: RecoveryThresholdEvidence, + freshness: FreshnessEvidence, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::try_new( + wire.protocol_version, + wire.expected_pending_recovery, + wire.threshold_evidence, + wire.freshness, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(CancelRecovery, "cancel recovery operation bytes"); + +/// Provider-observed begin intent and its deterministic quorum delay anchor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecoveryDelayAnchor { + protocol_version: ProtocolVersion, + account_id: AccountId, + recovery_id: RecoveryId, + begin_proposal_id: ProposalId, + provider_policy_id: ProviderPolicyId, + required_quorum: ProviderQuorum, + observed_at: Timestamp, + receipts: ProviderReceipts, + extensions: Extensions, +} + +impl RecoveryDelayAnchor { + /// Construct evidence whose anchor is the quorum-th earliest distinct observation. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + protocol_version: ProtocolVersion, + account_id: AccountId, + recovery_id: RecoveryId, + begin_proposal_id: ProposalId, + provider_policy_id: ProviderPolicyId, + required_quorum: ProviderQuorum, + receipts: ProviderReceipts, + extensions: Extensions, + ) -> Result { + let observed_at = + Self::derive_observed_at(account_id, begin_proposal_id, required_quorum, &receipts)?; + Self::from_wire( + protocol_version, + account_id, + recovery_id, + begin_proposal_id, + provider_policy_id, + required_quorum, + observed_at, + receipts, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_wire( + protocol_version: ProtocolVersion, + account_id: AccountId, + recovery_id: RecoveryId, + begin_proposal_id: ProposalId, + provider_policy_id: ProviderPolicyId, + required_quorum: ProviderQuorum, + observed_at: Timestamp, + receipts: ProviderReceipts, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + let expected = + Self::derive_observed_at(account_id, begin_proposal_id, required_quorum, &receipts)?; + if observed_at != expected { + return Err(IdentityError::InvalidRelationship { + resource: "recovery delay anchor timestamp", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + account_id, + recovery_id, + begin_proposal_id, + provider_policy_id, + required_quorum, + observed_at, + receipts, + extensions, + }) + } + + fn derive_observed_at( + account_id: AccountId, + begin_proposal_id: ProposalId, + required_quorum: ProviderQuorum, + receipts: &ProviderReceipts, + ) -> Result { + let quorum = usize::from(required_quorum.get()); + if receipts.as_slice().len() < quorum { + return Err(IdentityError::UnsatisfiableThreshold); + } + let mut observations = Vec::with_capacity(receipts.as_slice().len()); + for receipt in receipts.as_slice() { + if receipt.entry().account_id() != account_id + || receipt.entry().subject() + != crate::ProviderLogSubject::EventIntent(begin_proposal_id) + { + return Err(IdentityError::InvalidRelationship { + resource: "recovery delay receipt subject", + }); + } + observations.push(receipt.entry().observed_at()); + } + observations.sort_unstable(); + Ok(observations[quorum - 1]) + } + + /// Account whose recovery begin intent was observed. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact recovery proposal whose delay is anchored. + pub const fn recovery_id(&self) -> RecoveryId { + self.recovery_id + } + + /// Exact begin-event proposal whose provider observations started the delay. + pub const fn begin_proposal_id(&self) -> ProposalId { + self.begin_proposal_id + } + + /// Pre-recovery provider policy under which the observation quorum was evaluated. + pub const fn provider_policy_id(&self) -> ProviderPolicyId { + self.provider_policy_id + } + + /// Minimum number of distinct configured provider observations required. + pub const fn required_quorum(&self) -> ProviderQuorum { + self.required_quorum + } + + /// Deterministic quorum-th earliest provider observation. + pub const fn observed_at(&self) -> Timestamp { + self.observed_at + } + + /// Sorted receipts from distinct providers. + pub const fn receipts(&self) -> &ProviderReceipts { + &self.receipts + } +} + +impl<'de> Deserialize<'de> for RecoveryDelayAnchor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + recovery_id: RecoveryId, + begin_proposal_id: ProposalId, + provider_policy_id: ProviderPolicyId, + required_quorum: ProviderQuorum, + observed_at: Timestamp, + receipts: ProviderReceipts, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::from_wire( + wire.protocol_version, + wire.account_id, + wire.recovery_id, + wire.begin_proposal_id, + wire.provider_policy_id, + wire.required_quorum, + wire.observed_at, + wire.receipts, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(RecoveryDelayAnchor, "recovery delay anchor bytes"); + +/// Finalize the exact authoritative pending recovery after its provider-observed delay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FinalizeRecovery { + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + delay_anchor: RecoveryDelayAnchor, + finalized_at: Timestamp, + extensions: Extensions, +} + +impl FinalizeRecovery { + /// Construct finalization for one exact occupied recovery slot. + pub fn try_new( + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + delay_anchor: RecoveryDelayAnchor, + finalized_at: Timestamp, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if expected_pending_recovery != delay_anchor.recovery_id() { + return Err(IdentityError::InvalidRelationship { + resource: "finalize recovery delay anchor", + }); + } + if finalized_at < delay_anchor.observed_at() { + return Err(IdentityError::InvalidRelationship { + resource: "finalize recovery time", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + expected_pending_recovery, + delay_anchor, + finalized_at, + extensions, + }) + } + + /// Pending recovery that must exist when finalization is applied. + pub const fn expected_pending_recovery(&self) -> RecoveryId { + self.expected_pending_recovery + } + + /// Quorum-observed delay anchor for the begin intent. + pub const fn delay_anchor(&self) -> &RecoveryDelayAnchor { + &self.delay_anchor + } + + /// Explicit historical finalization time checked against the delay and expiry. + pub const fn finalized_at(&self) -> Timestamp { + self.finalized_at + } +} + +impl<'de> Deserialize<'de> for FinalizeRecovery { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + expected_pending_recovery: RecoveryId, + delay_anchor: RecoveryDelayAnchor, + finalized_at: Timestamp, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::try_new( + wire.protocol_version, + wire.expected_pending_recovery, + wire.delay_anchor, + wire.finalized_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(FinalizeRecovery, "finalize recovery operation bytes"); + +/// Exact last authority object shared by every branch in a fork. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ForkCommonAncestor { + /// The branches conflict at the first account event. + Genesis(GenesisAnchor), + /// The branches share one ordinary account event. + Event(EventId), +} + +impl ForkCommonAncestor { + /// Genesis anchor when the conflict is between first events. + pub const fn genesis_anchor(self) -> Option { + match self { + Self::Genesis(anchor) => Some(anchor), + Self::Event(_) => None, + } + } + + /// Ordinary common event when at least one event precedes the branches. + pub const fn event_id(self) -> Option { + match self { + Self::Genesis(_) => None, + Self::Event(event_id) => Some(event_id), + } + } +} + +impl Serialize for ForkCommonAncestor { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Genesis(anchor) => (1_u16, anchor).serialize(serializer), + Self::Event(event_id) => (2_u16, event_id).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for ForkCommonAncestor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + impl<'de> de::Visitor<'de> for Visitor { + type Value = ForkCommonAncestor; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("v1 fork common ancestor") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let code = sequence + .next_element::()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + match code { + 1 => sequence + .next_element::()? + .map(ForkCommonAncestor::Genesis) + .ok_or_else(|| de::Error::invalid_length(1, &self)), + 2 => sequence + .next_element::()? + .map(ForkCommonAncestor::Event) + .ok_or_else(|| de::Error::invalid_length(1, &self)), + unsupported => Err(de::Error::custom(IdentityError::UnsupportedCodepoint { + registry: "fork common ancestor", + code: unsupported, + })), + } + } + } + deserializer.deserialize_tuple(2, Visitor) + } +} + +canonical_schema!(ForkCommonAncestor, "fork common ancestor bytes"); + +/// Complete bounded descriptor of all currently known heads of one account fork. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ForkDescriptor { + protocol_version: ProtocolVersion, + account_id: AccountId, + common_ancestor: ForkCommonAncestor, + heads: BoundedVec, + extensions: Extensions, +} + +impl ForkDescriptor { + /// Sort and construct a complete set of at least two distinct branch heads. + pub fn try_new( + protocol_version: ProtocolVersion, + account_id: AccountId, + common_ancestor: ForkCommonAncestor, + mut heads: Vec, + extensions: Extensions, + ) -> Result { + heads.sort_unstable(); + Self::from_sorted( + protocol_version, + account_id, + common_ancestor, + heads, + extensions, + ) + } + + fn from_sorted( + protocol_version: ProtocolVersion, + account_id: AccountId, + common_ancestor: ForkCommonAncestor, + heads: Vec, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if heads.len() < 2 { + return Err(IdentityError::EmptyCollection { + resource: "fork branch heads", + }); + } + validate_strictly_sorted(&heads, "fork branch heads")?; + if common_ancestor + .event_id() + .is_some_and(|ancestor| heads.binary_search(&ancestor).is_ok()) + { + return Err(IdentityError::InvalidRelationship { + resource: "fork ancestor/head", + }); + } + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + account_id, + common_ancestor, + heads: BoundedVec::new("fork branch heads", heads)?, + extensions, + }) + } + + /// Account whose control history forked. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Last authority object shared by every declared branch. + pub const fn common_ancestor(&self) -> ForkCommonAncestor { + self.common_ancestor + } + + /// Complete sorted set of currently known branch heads. + pub fn heads(&self) -> &[EventId] { + self.heads.as_slice() + } + + /// Derive the identifier from only the common ancestor and complete head set. + pub fn fork_id(&self) -> Result { + let encoded = encode_wire(&(self.common_ancestor, self.heads.as_slice()))?; + Ok(ForkId::from_digest(hash_bytes(HashDomain::Fork, &encoded))) + } +} + +impl<'de> Deserialize<'de> for ForkDescriptor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + common_ancestor: ForkCommonAncestor, + heads: BoundedVec, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::from_sorted( + wire.protocol_version, + wire.account_id, + wire.common_ancestor, + wire.heads.into_vec(), + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(ForkDescriptor, "fork descriptor bytes"); + +/// V1 fork resolution: choose one declared branch and add monotonic revocations. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ResolveFork { + protocol_version: ProtocolVersion, + fork_id: ForkId, + fork: ForkDescriptor, + selected_head: EventId, + revoked_controllers: BoundedVec, + revoked_devices: BoundedVec, + extensions: Extensions, +} + +impl ResolveFork { + /// Construct a choose-one-branch resolution with sorted additive revocations. + pub fn try_new( + protocol_version: ProtocolVersion, + fork: ForkDescriptor, + selected_head: EventId, + mut revoked_controllers: Vec, + mut revoked_devices: Vec, + extensions: Extensions, + ) -> Result { + let fork_id = fork.fork_id()?; + revoked_controllers.sort_unstable(); + revoked_devices.sort_unstable(); + Self::from_sorted( + protocol_version, + fork_id, + fork, + selected_head, + revoked_controllers, + revoked_devices, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_sorted( + protocol_version: ProtocolVersion, + fork_id: ForkId, + fork: ForkDescriptor, + selected_head: EventId, + revoked_controllers: Vec, + revoked_devices: Vec, + extensions: Extensions, + ) -> Result { + validate_v1(protocol_version)?; + if fork.fork_id()? != fork_id { + return Err(IdentityError::InvalidIdentifier { + resource: "fork resolution descriptor", + }); + } + if fork.heads().binary_search(&selected_head).is_err() { + return Err(IdentityError::InvalidRelationship { + resource: "fork selected branch", + }); + } + validate_strictly_sorted(&revoked_controllers, "fork controller revocations")?; + validate_strictly_sorted(&revoked_devices, "fork device revocations")?; + extensions.validate_critical(&[])?; + Ok(Self { + protocol_version, + fork_id, + fork, + selected_head, + revoked_controllers: BoundedVec::new( + "fork controller revocations", + revoked_controllers, + )?, + revoked_devices: BoundedVec::new("fork device revocations", revoked_devices)?, + extensions, + }) + } + + /// Complete fork descriptor whose revision/head set must still match. + pub const fn fork(&self) -> &ForkDescriptor { + &self.fork + } + + /// Exact existing branch selected as the authority basis. + pub const fn selected_head(&self) -> EventId { + self.selected_head + } + + /// Sorted controller revocations added to the selected branch state. + pub fn revoked_controllers(&self) -> &[ControllerId] { + self.revoked_controllers.as_slice() + } + + /// Sorted device revocations added to the selected branch state. + pub fn revoked_devices(&self) -> &[DeviceId] { + self.revoked_devices.as_slice() + } +} + +impl<'de> Deserialize<'de> for ResolveFork { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + fork_id: ForkId, + fork: ForkDescriptor, + selected_head: EventId, + revoked_controllers: BoundedVec, + revoked_devices: BoundedVec, + extensions: Extensions, + } + let wire = Wire::deserialize(deserializer)?; + Self::from_sorted( + wire.protocol_version, + wire.fork_id, + wire.fork, + wire.selected_head, + wire.revoked_controllers.into_vec(), + wire.revoked_devices.into_vec(), + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +canonical_schema!(ResolveFork, "resolve fork operation bytes"); diff --git a/protocols/krikos-identity/src/redb_guard.rs b/protocols/krikos-identity/src/redb_guard.rs new file mode 100644 index 00000000000..bf09f5e0b87 --- /dev/null +++ b/protocols/krikos-identity/src/redb_guard.rs @@ -0,0 +1,118 @@ +//! Fail-closed validation before redb opens an existing durable store. + +use std::{ + fs::{self, File}, + io::{ErrorKind, Read}, + mem::size_of, + path::Path, +}; + +use crate::IdentityError; + +// redb 4.1's frozen on-disk super-header fields. The dependency is pinned by Cargo.lock. Keeping +// this small parser at the adapter boundary prevents redb's internal layout assertions from seeing +// crash-truncated files. A future redb layout must fail closed here until reviewed. +const REDB_MAGIC: [u8; 9] = [b'r', b'e', b'd', b'b', 0x1a, 0x0a, 0xa9, 0x0d, 0x0a]; +const REDB_HEADER_BYTES: usize = 320; +const REDB_LAYOUT_PREFIX_BYTES: usize = 32; +const REDB_PAGE_SIZE: u64 = 4_096; +const PAGE_SIZE_OFFSET: usize = 12; +const REGION_HEADER_PAGES_OFFSET: usize = 16; +const REGION_MAX_DATA_PAGES_OFFSET: usize = 20; +const NUM_FULL_REGIONS_OFFSET: usize = 24; +const TRAILING_REGION_DATA_PAGES_OFFSET: usize = 28; + +pub(crate) fn validate_existing_redb_file(path: &Path) -> Result<(), IdentityError> { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(IdentityError::StorageCorruption), + }; + let actual_length = metadata.len(); + if !metadata.is_file() + || actual_length < REDB_HEADER_BYTES as u64 + || actual_length % REDB_PAGE_SIZE != 0 + { + return Err(IdentityError::StorageCorruption); + } + + let mut prefix = [0_u8; REDB_LAYOUT_PREFIX_BYTES]; + File::open(path) + .and_then(|mut file| file.read_exact(&mut prefix)) + .map_err(|_| IdentityError::StorageCorruption)?; + if prefix[..REDB_MAGIC.len()] != REDB_MAGIC { + return Err(IdentityError::StorageCorruption); + } + + let page_size = u64::from(read_u32(&prefix, PAGE_SIZE_OFFSET)?); + let region_header_pages = u64::from(read_u32(&prefix, REGION_HEADER_PAGES_OFFSET)?); + let region_max_data_pages = u64::from(read_u32(&prefix, REGION_MAX_DATA_PAGES_OFFSET)?); + let full_regions = u64::from(read_u32(&prefix, NUM_FULL_REGIONS_OFFSET)?); + let trailing_data_pages = u64::from(read_u32(&prefix, TRAILING_REGION_DATA_PAGES_OFFSET)?); + if page_size != REDB_PAGE_SIZE + || region_max_data_pages == 0 + || trailing_data_pages > region_max_data_pages + || (full_regions == 0 && trailing_data_pages == 0) + { + return Err(IdentityError::StorageCorruption); + } + + let full_region_pages = region_header_pages + .checked_add(region_max_data_pages) + .ok_or(IdentityError::StorageCorruption)?; + let full_region_bytes = full_region_pages + .checked_mul(page_size) + .ok_or(IdentityError::StorageCorruption)?; + let full_bytes = full_regions + .checked_mul(full_region_bytes) + .ok_or(IdentityError::StorageCorruption)?; + let trailing_bytes = if trailing_data_pages == 0 { + 0 + } else { + region_header_pages + .checked_add(trailing_data_pages) + .and_then(|pages| pages.checked_mul(page_size)) + .ok_or(IdentityError::StorageCorruption)? + }; + let header_and_regions = page_size + .checked_add(full_bytes) + .and_then(|length| length.checked_add(trailing_bytes)) + .ok_or(IdentityError::StorageCorruption)?; + if actual_length < header_and_regions { + return Err(IdentityError::StorageCorruption); + } + Ok(()) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let end = offset + .checked_add(size_of::()) + .ok_or(IdentityError::StorageCorruption)?; + let encoded = bytes + .get(offset..end) + .ok_or(IdentityError::StorageCorruption)?; + Ok(u32::from_le_bytes( + encoded + .try_into() + .map_err(|_| IdentityError::StorageCorruption)?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_is_creatable_but_existing_short_file_fails_closed() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing.redb"); + assert_eq!(validate_existing_redb_file(&missing), Ok(())); + + let short = directory.path().join("short.redb"); + fs::write(&short, REDB_MAGIC).unwrap(); + assert_eq!( + validate_existing_redb_file(&short), + Err(IdentityError::StorageCorruption) + ); + } +} diff --git a/protocols/krikos-identity/src/schema.rs b/protocols/krikos-identity/src/schema.rs new file mode 100644 index 00000000000..a8b5a45fb32 --- /dev/null +++ b/protocols/krikos-identity/src/schema.rs @@ -0,0 +1,758 @@ +//! Reusable bounded wire machinery and checked schema scalars. + +use std::{fmt, marker::PhantomData}; + +use serde::{Deserialize, Deserializer, Serialize, de, ser::SerializeSeq}; + +use crate::{ + CanonicalWire, Digest, IdentityError, ProtocolSignature, SigningPublicKey, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_ALGORITHM_PUBLIC_KEY_BYTES, MAX_ALGORITHM_SIGNATURE_BYTES, MAX_DELEGATION_DEPTH}, + types::{HashDomain, hash_bytes}, +}; + +macro_rules! digest_id { + ($name:ident, $domain:ident, $doc:literal) => { + #[doc = $doc] + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + pub struct $name(Digest); + + impl $name { + /// Borrow the algorithm-tagged digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } + + #[allow(dead_code)] // Schema modules consume each derivation incrementally. + pub(crate) fn derive(body: &T) -> Result { + let bytes = body.to_canonical_bytes()?; + Ok(Self(hash_bytes(HashDomain::$domain, &bytes))) + } + + #[allow(dead_code)] // Projection-only IDs need this in later planned tasks. + pub(crate) const fn from_digest(digest: Digest) -> Self { + Self(digest) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple(stringify!($name)) + .field(&self.0) + .finish() + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } + } + + impl CanonicalCodec for $name { + const RESOURCE: &'static str = concat!(stringify!($name), " bytes"); + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +digest_id!( + GenesisAnchor, + GenesisAnchor, + "First-event predecessor derived from account genesis." +); +digest_id!( + AccountId, + AccountId, + "Stable account identifier derived from canonical genesis." +); +digest_id!( + ControllerId, + ControllerDescriptor, + "Stable account-controller identifier." +); +digest_id!( + ControllerKeyId, + ControllerKey, + "Controller key binding identifier." +); +digest_id!( + ControlPolicyId, + ControlPolicy, + "Canonical control-policy identifier." +); +digest_id!( + RecoveryPolicyId, + RecoveryPolicy, + "Canonical recovery-policy identifier." +); +digest_id!( + ProviderId, + Provider, + "Self-certifying transparency-provider identifier." +); +digest_id!( + ProviderLogId, + ProviderLog, + "Transparency-provider log identifier." +); +digest_id!( + ProviderPolicyId, + ProviderPolicy, + "Canonical account provider-policy identifier." +); +digest_id!( + DeviceId, + DeviceDescriptor, + "Replaceable independently keyed device identifier." +); +digest_id!( + CapabilityGrantId, + CapabilityGrant, + "Canonical capability-grant identifier." +); +digest_id!( + DelegationId, + CapabilityDelegation, + "Canonical capability-delegation identifier." +); +digest_id!( + ProposalId, + AccountProposal, + "Proposal-domain identifier of an account event body." +); +/// Authoritative identifier of an account event body and its exact admission evidence. +/// +/// Unlike body-derived identifiers, this type deliberately has no generic `derive` helper. The +/// only v1 derivation is the acyclic admitted-event construction in `event`. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct EventId(Digest); + +impl EventId { + /// Borrow the algorithm-tagged digest. + pub const fn as_digest(&self) -> &Digest { + &self.0 + } + + pub(crate) const fn from_digest(digest: Digest) -> Self { + Self(digest) + } +} + +impl fmt::Debug for EventId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("EventId").field(&self.0).finish() + } +} + +impl fmt::Display for EventId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl CanonicalCodec for EventId { + const RESOURCE: &'static str = "EventId bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} +digest_id!( + EventAuthorizationId, + EventAuthorization, + "Domain-separated identifier of a complete authorized-event envelope." +); +digest_id!( + AdmissionEvidenceId, + AdmissionEvidence, + "Historical event-admission evidence identifier." +); +digest_id!( + ControllerApprovalId, + ControllerApproval, + "Controller approval-body identifier." +); +digest_id!( + EventIntentApprovalId, + EventIntentApproval, + "Controller proposal-intent approval-body identifier." +); +digest_id!( + CheckpointId, + AccountCheckpoint, + "Canonical checkpoint-body identifier." +); +digest_id!( + RecoveryId, + Recovery, + "Canonical recovery-proposal identifier." +); +digest_id!( + GuardianGrantId, + GuardianGrant, + "Blinded recovery guardian-grant identifier." +); +digest_id!( + ForkId, + Fork, + "Canonical complete fork descriptor identifier." +); +digest_id!( + CryptoSuiteId, + CryptoSuite, + "Canonical cryptographic-suite identifier." +); +digest_id!( + CryptoMigrationId, + CryptoMigration, + "Canonical cryptographic-migration identifier." +); +digest_id!( + CryptoStateId, + CryptoState, + "Projected cryptographic-state identifier." +); +digest_id!( + ApplicationId, + ApplicationId, + "Application-supplied typed identifier." +); +digest_id!( + ApplicationEventId, + ApplicationEvent, + "Canonical signed application-event identifier." +); +digest_id!(GroupId, GroupId, "Application-supplied group identifier."); +digest_id!( + GroupKeyWrapId, + GroupKeyWrap, + "Canonical wrapped group-key identifier." +); + +impl ControllerKeyId { + /// Derive the v1 key identifier used by approvals for an Ed25519 controller key. + pub fn for_signing_key(key: &SigningPublicKey) -> Result { + Self::derive(key) + } + + /// Derive a migration-era key identifier from bounded algorithm-tagged key material. + pub fn for_algorithm_key(key: &AlgorithmPublicKey) -> Result { + Self::derive(key) + } +} + +impl ApplicationId { + /// Construct an application-supplied v1 digest identifier. + pub const fn new(digest: Digest) -> Self { + Self::from_digest(digest) + } +} + +impl GroupId { + /// Construct an application-supplied v1 digest identifier. + pub const fn new(digest: Digest) -> Self { + Self::from_digest(digest) + } +} + +/// A nonzero controller weight. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct ControllerWeight(u32); + +/// A nonzero authorization threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct RequiredWeight(u32); + +/// A nonzero number of transparency providers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct ProviderQuorum(u16); + +/// A bounded, nonzero remaining capability-delegation depth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct DelegationDepth(u8); + +/// A nonzero public revocation reason code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct RevocationReasonCode(u16); + +/// A nonzero target protocol major, including locally unsupported future versions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct ProtocolMajor(u16); + +macro_rules! nonzero_scalar { + ($name:ident, $integer:ty, $resource:literal) => { + impl $name { + /// Construct a nonzero schema value. + pub const fn new(value: $integer) -> Result { + if value == 0 { + return Err(IdentityError::ZeroValue { + resource: $resource, + }); + } + Ok(Self(value)) + } + + /// Return the exact wire value. + pub const fn get(self) -> $integer { + self.0 + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = <$integer>::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } + } + + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +nonzero_scalar!(ControllerWeight, u32, "controller weight"); +nonzero_scalar!(RequiredWeight, u32, "required authorization weight"); +nonzero_scalar!(ProviderQuorum, u16, "provider quorum"); +nonzero_scalar!(RevocationReasonCode, u16, "revocation reason code"); +nonzero_scalar!(ProtocolMajor, u16, "protocol major"); + +impl DelegationDepth { + /// Construct a depth in the closed v1 range `1..=8`. + pub const fn new(depth: u8) -> Result { + if depth == 0 { + return Err(IdentityError::ZeroValue { + resource: "delegation depth", + }); + } + if depth as usize > MAX_DELEGATION_DEPTH { + return Err(IdentityError::LimitExceeded { + resource: "delegation depth", + actual: depth as usize, + maximum: MAX_DELEGATION_DEPTH, + }); + } + Ok(Self(depth)) + } + + /// Remaining delegation depth. + pub const fn get(self) -> u8 { + self.0 + } +} + +impl<'de> Deserialize<'de> for DelegationDepth { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(u8::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for DelegationDepth { + const RESOURCE: &'static str = "delegation depth"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +macro_rules! checked_counter { + ($name:ident, $zero:ident, $resource:literal) => { + /// A checked monotonic schema counter. + #[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, + )] + pub struct $name(u64); + + impl $name { + /// Initial zero value. + pub const $zero: Self = Self(0); + + /// Construct from the exact wire value. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Return the exact wire value. + pub const fn get(self) -> u64 { + self.0 + } + + /// Advance exactly once, rejecting exhaustion. + pub fn checked_next(self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or(IdentityError::ArithmeticOverflow { + resource: $resource, + }) + } + } + + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } + } + }; +} + +checked_counter!(ProviderPolicyVersion, GENESIS, "provider policy version"); +checked_counter!(ProviderKeyVersion, GENESIS, "provider key version"); +checked_counter!(GroupKeyEpoch, GENESIS, "group key epoch"); + +/// A length-bounded algorithm-tagged public signing key used during migration. +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct AlgorithmPublicKey { + algorithm_code: u16, + bytes: BoundedBytes, +} + +impl AlgorithmPublicKey { + /// Construct bounded key material. Code `1` enforces the exact Ed25519 profile. + pub fn new(algorithm_code: u16, bytes: Vec) -> Result { + if algorithm_code == 0 { + return Err(IdentityError::ZeroValue { + resource: "signature algorithm code", + }); + } + if bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "algorithm public key bytes", + }); + } + let bytes = BoundedBytes::new("algorithm public key bytes", bytes)?; + if algorithm_code == 1 { + let key_bytes: [u8; 32] = + bytes + .as_slice() + .try_into() + .map_err(|_| IdentityError::InvalidPublicKey { + kind: crate::AlgorithmKind::Signature, + })?; + SigningPublicKey::ed25519(key_bytes)?; + } + Ok(Self { + algorithm_code, + bytes, + }) + } + + /// Registry code for this key's signature algorithm. + pub const fn algorithm_code(&self) -> u16 { + self.algorithm_code + } + + /// Exact public key bytes. + pub fn as_bytes(&self) -> &[u8] { + self.bytes.as_slice() + } +} + +impl fmt::Debug for AlgorithmPublicKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AlgorithmPublicKey") + .field("algorithm_code", &self.algorithm_code) + .field( + "bytes", + &format_args!("<{} public bytes>", self.bytes.len()), + ) + .finish() + } +} + +impl<'de> Deserialize<'de> for AlgorithmPublicKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + algorithm_code: u16, + bytes: BoundedBytes, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.algorithm_code, wire.bytes.into_vec()).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for AlgorithmPublicKey { + const RESOURCE: &'static str = "algorithm public key bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// A length-bounded algorithm-tagged signature used during migration. +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct AlgorithmSignature { + algorithm_code: u16, + bytes: BoundedBytes, +} + +impl AlgorithmSignature { + /// Construct bounded signature material. Code `1` requires 64 Ed25519 bytes. + pub fn new(algorithm_code: u16, bytes: Vec) -> Result { + if algorithm_code == 0 { + return Err(IdentityError::ZeroValue { + resource: "signature algorithm code", + }); + } + if bytes.is_empty() { + return Err(IdentityError::EmptyCollection { + resource: "algorithm signature bytes", + }); + } + let bytes = BoundedBytes::new("algorithm signature bytes", bytes)?; + if algorithm_code == 1 { + let signature_bytes: [u8; 64] = bytes + .as_slice() + .try_into() + .map_err(|_| IdentityError::InvalidEncoding)?; + let _ = ProtocolSignature::ed25519(signature_bytes); + } + Ok(Self { + algorithm_code, + bytes, + }) + } + + /// Registry code for this signature's algorithm. + pub const fn algorithm_code(&self) -> u16 { + self.algorithm_code + } + + /// Exact signature bytes. + pub fn as_bytes(&self) -> &[u8] { + self.bytes.as_slice() + } +} + +impl fmt::Debug for AlgorithmSignature { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AlgorithmSignature") + .field("algorithm_code", &self.algorithm_code) + .field("signature", &"") + .finish() + } +} + +impl<'de> Deserialize<'de> for AlgorithmSignature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + algorithm_code: u16, + bytes: BoundedBytes, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.algorithm_code, wire.bytes.into_vec()).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for AlgorithmSignature { + const RESOURCE: &'static str = "algorithm signature bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct BoundedBytes(Vec); + +impl BoundedBytes { + pub(crate) fn new(resource: &'static str, bytes: Vec) -> Result { + if bytes.len() > MAX { + return Err(IdentityError::limit(resource, bytes.len(), MAX)); + } + Ok(Self(bytes)) + } + + pub(crate) fn as_slice(&self) -> &[u8] { + &self.0 + } + + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + pub(crate) fn into_vec(self) -> Vec { + self.0 + } +} + +impl Serialize for BoundedBytes { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_seq(self.0.iter()) + } +} + +impl<'de, const MAX: usize> Deserialize<'de> for BoundedBytes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de, const MAX: usize> de::Visitor<'de> for Visitor { + type Value = BoundedBytes; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "at most {MAX} bytes") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + if sequence.size_hint().is_some_and(|hint| hint > MAX) { + return Err(de::Error::invalid_length(MAX.saturating_add(1), &self)); + } + let mut bytes = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX)); + while let Some(byte) = sequence.next_element()? { + if bytes.len() == MAX { + return Err(de::Error::invalid_length(MAX.saturating_add(1), &self)); + } + bytes.push(byte); + } + Ok(BoundedBytes(bytes)) + } + } + + deserializer.deserialize_seq(Visitor::) + } +} + +#[allow(dead_code)] // Consumed by bounded Task 2 collection schemas. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct BoundedVec(Vec); + +#[allow(dead_code)] // Consumed by bounded Task 2 collection schemas. +impl BoundedVec { + pub(crate) fn new(resource: &'static str, values: Vec) -> Result { + if values.len() > MAX { + return Err(IdentityError::limit(resource, values.len(), MAX)); + } + Ok(Self(values)) + } + + pub(crate) fn as_slice(&self) -> &[T] { + &self.0 + } + + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(crate) fn into_vec(self) -> Vec { + self.0 + } +} + +impl Serialize for BoundedVec { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut sequence = serializer.serialize_seq(Some(self.0.len()))?; + for value in &self.0 { + sequence.serialize_element(value)?; + } + sequence.end() + } +} + +impl<'de, T: Deserialize<'de>, const MAX: usize> Deserialize<'de> for BoundedVec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor(PhantomData); + + impl<'de, T: Deserialize<'de>, const MAX: usize> de::Visitor<'de> for Visitor { + type Value = BoundedVec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "at most {MAX} sequence elements") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + if sequence.size_hint().is_some_and(|hint| hint > MAX) { + return Err(de::Error::invalid_length(MAX.saturating_add(1), &self)); + } + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX)); + while let Some(value) = sequence.next_element()? { + if values.len() == MAX { + return Err(de::Error::invalid_length(MAX.saturating_add(1), &self)); + } + values.push(value); + } + Ok(BoundedVec(values)) + } + } + + deserializer.deserialize_seq(Visitor::(PhantomData)) + } +} diff --git a/protocols/krikos-identity/src/social.rs b/protocols/krikos-identity/src/social.rs new file mode 100644 index 00000000000..2a914a6a6d2 --- /dev/null +++ b/protocols/krikos-identity/src/social.rs @@ -0,0 +1,595 @@ +//! Signed, non-authoritative social attestations and explicitly bounded trust hints. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + AccountId, AlgorithmSignature, CheckpointId, Digest, Extensions, IdentityError, + ProtocolVersion, SigningPublicKey, Timestamp, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{MAX_SOCIAL_ATTESTATION_BYTES, MAX_SOCIAL_TRANSITIVITY_DEPTH}, +}; + +const SOCIAL_ATTESTATION_SIGNING_DOMAIN: &[u8] = b"KRIKOS-ID/social-attestation/v1"; + +/// Exact subject and issuer facts covered by one social statement. +/// +/// A social attestation is only a hint or trust input. It never grants account, +/// recovery, storage, provider, or device authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SocialAttestationBody { + protocol_version: ProtocolVersion, + issuer_account_id: AccountId, + issuer_checkpoint_id: CheckpointId, + issuer_signing_key: SigningPublicKey, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + claim_digest: Digest, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, +} + +impl SocialAttestationBody { + /// Construct one exact, optionally expiring social statement. + #[allow(clippy::too_many_arguments)] + pub fn try_new( + issuer_account_id: AccountId, + issuer_checkpoint_id: CheckpointId, + issuer_signing_key: SigningPublicKey, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + claim_digest: Digest, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, + ) -> Result { + Self::from_parts( + issuer_account_id, + issuer_checkpoint_id, + issuer_signing_key, + subject_account_id, + subject_checkpoint_id, + subject_signing_key, + claim_digest, + issued_at, + expires_at, + extensions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn from_parts( + issuer_account_id: AccountId, + issuer_checkpoint_id: CheckpointId, + issuer_signing_key: SigningPublicKey, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + claim_digest: Digest, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, + ) -> Result { + if issuer_account_id == subject_account_id { + return Err(IdentityError::InvalidRelationship { + resource: "social attestation issuer and subject", + }); + } + if expires_at.is_some_and(|expiry| expiry <= issued_at) { + return Err(IdentityError::InvalidRelationship { + resource: "social attestation validity interval", + }); + } + extensions.validate_critical(&[])?; + let body = Self { + protocol_version: ProtocolVersion::V1, + issuer_account_id, + issuer_checkpoint_id, + issuer_signing_key, + subject_account_id, + subject_checkpoint_id, + subject_signing_key, + claim_digest, + issued_at, + expires_at, + extensions, + }; + let encoded_len = encode_wire(&body)?.len(); + if encoded_len > MAX_SOCIAL_ATTESTATION_BYTES { + return Err(IdentityError::limit( + "social attestation body bytes", + encoded_len, + MAX_SOCIAL_ATTESTATION_BYTES, + )); + } + Ok(body) + } + + /// Domain-separated canonical bytes signed by the issuer. + pub fn signing_bytes(&self) -> Result, IdentityError> { + domain_message(SOCIAL_ATTESTATION_SIGNING_DOMAIN, &encode_wire(self)?) + } + + /// Issuer account authenticated by the caller's verification context. + pub const fn issuer_account_id(&self) -> AccountId { + self.issuer_account_id + } + + /// Exact issuer checkpoint authenticated by the caller. + pub const fn issuer_checkpoint_id(&self) -> CheckpointId { + self.issuer_checkpoint_id + } + + /// Exact issuer key which signs this statement. + pub const fn issuer_signing_key(&self) -> SigningPublicKey { + self.issuer_signing_key + } + + /// Subject account named by this statement. + pub const fn subject_account_id(&self) -> AccountId { + self.subject_account_id + } + + /// Exact subject checkpoint named by this statement. + pub const fn subject_checkpoint_id(&self) -> CheckpointId { + self.subject_checkpoint_id + } + + /// Exact subject key named by this statement. + pub const fn subject_signing_key(&self) -> SigningPublicKey { + self.subject_signing_key + } + + /// Digest of the private or selectively disclosed claim. + pub const fn claim_digest(&self) -> Digest { + self.claim_digest + } + + /// Explicit statement issuance time. + pub const fn issued_at(&self) -> Timestamp { + self.issued_at + } + + /// Optional exclusive statement expiry. + pub const fn expires_at(&self) -> Option { + self.expires_at + } +} + +impl<'de> Deserialize<'de> for SocialAttestationBody { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + issuer_account_id: AccountId, + issuer_checkpoint_id: CheckpointId, + issuer_signing_key: SigningPublicKey, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + claim_digest: Digest, + issued_at: Timestamp, + expires_at: Option, + extensions: Extensions, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + Self::from_parts( + wire.issuer_account_id, + wire.issuer_checkpoint_id, + wire.issuer_signing_key, + wire.subject_account_id, + wire.subject_checkpoint_id, + wire.subject_signing_key, + wire.claim_digest, + wire.issued_at, + wire.expires_at, + wire.extensions, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for SocialAttestationBody { + const RESOURCE: &'static str = "social attestation body bytes"; + const MAX_ENCODED_BYTES: usize = MAX_SOCIAL_ATTESTATION_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// One issuer-signed social statement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedSocialAttestation { + body: SocialAttestationBody, + issuer_signature: AlgorithmSignature, +} + +impl SignedSocialAttestation { + /// Verify and retain the exact issuer signature. + pub fn try_new( + body: SocialAttestationBody, + issuer_signature: AlgorithmSignature, + ) -> Result { + verify_signature( + body.issuer_signing_key, + &issuer_signature, + &body.signing_bytes()?, + )?; + let signed = Self { + body, + issuer_signature, + }; + let encoded_len = encode_wire(&signed)?.len(); + if encoded_len > MAX_SOCIAL_ATTESTATION_BYTES { + return Err(IdentityError::limit( + "signed social attestation bytes", + encoded_len, + MAX_SOCIAL_ATTESTATION_BYTES, + )); + } + Ok(signed) + } + + /// Exact signed statement body. + pub const fn body(&self) -> &SocialAttestationBody { + &self.body + } + + /// Typed issuer signature. + pub const fn issuer_signature(&self) -> &AlgorithmSignature { + &self.issuer_signature + } +} + +impl<'de> Deserialize<'de> for SignedSocialAttestation { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (body, issuer_signature) = + <(SocialAttestationBody, AlgorithmSignature)>::deserialize(deserializer)?; + Self::try_new(body, issuer_signature).map_err(de::Error::custom) + } +} + +impl CanonicalCodec for SignedSocialAttestation { + const RESOURCE: &'static str = "signed social attestation bytes"; + const MAX_ENCODED_BYTES: usize = MAX_SOCIAL_ATTESTATION_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// Caller-supplied authoritative facts expected when checking an attestation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SocialAttestationVerificationContext { + issuer_account_id: AccountId, + issuer_checkpoint_id: CheckpointId, + issuer_signing_key: SigningPublicKey, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + claim_digest: Digest, + authority_time: Timestamp, +} + +impl SocialAttestationVerificationContext { + /// Construct exact caller-authenticated issuer, subject, claim, and time facts. + #[allow(clippy::too_many_arguments)] + pub const fn try_new( + issuer_account_id: AccountId, + issuer_checkpoint_id: CheckpointId, + issuer_signing_key: SigningPublicKey, + subject_account_id: AccountId, + subject_checkpoint_id: CheckpointId, + subject_signing_key: SigningPublicKey, + claim_digest: Digest, + authority_time: Timestamp, + ) -> Result { + Ok(Self { + issuer_account_id, + issuer_checkpoint_id, + issuer_signing_key, + subject_account_id, + subject_checkpoint_id, + subject_signing_key, + claim_digest, + authority_time, + }) + } +} + +/// A cryptographically checked social statement that grants no authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedSocialAttestation { + body: SocialAttestationBody, + authority_time: Timestamp, +} + +impl VerifiedSocialAttestation { + /// Issuer account at the authenticated checkpoint and key. + pub const fn issuer_account_id(&self) -> AccountId { + self.body.issuer_account_id + } + + /// Exact issuer checkpoint. + pub const fn issuer_checkpoint_id(&self) -> CheckpointId { + self.body.issuer_checkpoint_id + } + + /// Exact issuer signing key. + pub const fn issuer_signing_key(&self) -> SigningPublicKey { + self.body.issuer_signing_key + } + + /// Subject account named by the checked statement. + pub const fn subject_account_id(&self) -> AccountId { + self.body.subject_account_id + } + + /// Exact subject checkpoint. + pub const fn subject_checkpoint_id(&self) -> CheckpointId { + self.body.subject_checkpoint_id + } + + /// Exact subject signing key. + pub const fn subject_signing_key(&self) -> SigningPublicKey { + self.body.subject_signing_key + } + + /// Claim digest shared by a valid trust chain. + pub const fn claim_digest(&self) -> Digest { + self.body.claim_digest + } + + /// Exact caller-authenticated time basis at which this fact was verified. + pub const fn authority_time(&self) -> Timestamp { + self.authority_time + } + + /// Inclusive start of the signed validity interval. + pub const fn issued_at(&self) -> Timestamp { + self.body.issued_at + } + + /// Optional exclusive end of the signed validity interval. + pub const fn expires_at(&self) -> Option { + self.body.expires_at + } + + fn valid_at(&self, authority_time: Timestamp) -> bool { + authority_time >= self.issued_at() + && self + .expires_at() + .is_none_or(|expiry| authority_time < expiry) + } +} + +/// Verify one social statement against caller-authenticated exact facts and explicit time. +pub fn verify_social_attestation( + attestation: &SignedSocialAttestation, + context: &SocialAttestationVerificationContext, +) -> Result { + let body = attestation.body(); + if body.issuer_account_id != context.issuer_account_id + || body.issuer_checkpoint_id != context.issuer_checkpoint_id + || body.issuer_signing_key != context.issuer_signing_key + || body.subject_account_id != context.subject_account_id + || body.subject_checkpoint_id != context.subject_checkpoint_id + || body.subject_signing_key != context.subject_signing_key + || body.claim_digest != context.claim_digest + { + return Err(IdentityError::InvalidRelationship { + resource: "social attestation verification context", + }); + } + if context.authority_time < body.issued_at + || body + .expires_at + .is_some_and(|expiry| context.authority_time >= expiry) + { + return Err(IdentityError::StaleEvidence); + } + verify_signature( + body.issuer_signing_key, + attestation.issuer_signature(), + &body.signing_bytes()?, + )?; + Ok(VerifiedSocialAttestation { + body: body.clone(), + authority_time: context.authority_time, + }) +} + +/// Explicit policy for following social-attestation chains. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SocialTransitivityPolicy { + mode: TransitivityMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum TransitivityMode { + #[default] + Disabled, + Bounded { + max_depth: u8, + }, +} + +impl SocialTransitivityPolicy { + /// Enable transitivity with a nonzero protocol-bounded maximum depth. + pub fn bounded(max_depth: u8) -> Result { + if max_depth == 0 || usize::from(max_depth) > MAX_SOCIAL_TRANSITIVITY_DEPTH { + return Err(IdentityError::limit( + "social transitivity depth", + usize::from(max_depth), + MAX_SOCIAL_TRANSITIVITY_DEPTH, + )); + } + Ok(Self { + mode: TransitivityMode::Bounded { max_depth }, + }) + } +} + +/// Non-authoritative result of explicitly following a checked social chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SocialTrustHint { + issuer_account_id: AccountId, + subject_account_id: AccountId, + claim_digest: Digest, + depth: u8, + authority_time: Timestamp, +} + +impl SocialTrustHint { + /// First issuer in the checked chain. + pub const fn issuer_account_id(self) -> AccountId { + self.issuer_account_id + } + + /// Final subject reached by the checked chain. + pub const fn subject_account_id(self) -> AccountId { + self.subject_account_id + } + + /// Exact claim digest shared by every edge. + pub const fn claim_digest(self) -> Digest { + self.claim_digest + } + + /// Number of signed edges followed. + pub const fn depth(self) -> u8 { + self.depth + } + + /// Exact common authority-time basis shared by every edge in this hint. + pub const fn authority_time(self) -> Timestamp { + self.authority_time + } +} + +/// Follow an ordered social chain verified at one explicit common authority time. +pub fn evaluate_social_trust( + attestations: &[VerifiedSocialAttestation], + policy: SocialTransitivityPolicy, + authority_time: Timestamp, +) -> Result { + let first = attestations.first().ok_or(IdentityError::EmptyCollection { + resource: "social attestation chain", + })?; + let depth = u8::try_from(attestations.len()).map_err(|_| { + IdentityError::limit( + "social transitivity depth", + attestations.len(), + MAX_SOCIAL_TRANSITIVITY_DEPTH, + ) + })?; + let permitted_depth = match policy.mode { + TransitivityMode::Disabled => 1, + TransitivityMode::Bounded { max_depth } => max_depth, + }; + if depth > permitted_depth || usize::from(depth) > MAX_SOCIAL_TRANSITIVITY_DEPTH { + return Err(IdentityError::limit( + "social transitivity depth", + usize::from(depth), + usize::from(permitted_depth).min(MAX_SOCIAL_TRANSITIVITY_DEPTH), + )); + } + + for attestation in attestations { + if !attestation.valid_at(authority_time) { + return Err(IdentityError::StaleEvidence); + } + if attestation.authority_time() != authority_time { + return Err(IdentityError::InvalidRelationship { + resource: "social attestation common authority time", + }); + } + } + + let mut visited_accounts = BTreeSet::new(); + visited_accounts.insert(first.issuer_account_id()); + let mut prior = first; + if !visited_accounts.insert(first.subject_account_id()) { + return Err(IdentityError::InvalidRelationship { + resource: "cyclic social attestation chain", + }); + } + for attestation in &attestations[1..] { + if prior.subject_account_id() != attestation.issuer_account_id() + || prior.subject_checkpoint_id() != attestation.issuer_checkpoint_id() + || prior.subject_signing_key() != attestation.issuer_signing_key() + || first.claim_digest() != attestation.claim_digest() + { + return Err(IdentityError::InvalidRelationship { + resource: "social attestation chain", + }); + } + if !visited_accounts.insert(attestation.subject_account_id()) { + return Err(IdentityError::InvalidRelationship { + resource: "cyclic social attestation chain", + }); + } + prior = attestation; + } + + Ok(SocialTrustHint { + issuer_account_id: first.issuer_account_id(), + subject_account_id: prior.subject_account_id(), + claim_digest: first.claim_digest(), + depth, + authority_time, + }) +} + +fn verify_signature( + signing_key: SigningPublicKey, + signature: &AlgorithmSignature, + message: &[u8], +) -> Result<(), IdentityError> { + crate::verifier::verify_algorithm_signature( + signing_key.algorithm().code(), + signing_key.as_bytes(), + signature, + message, + ) +} + +fn domain_message(domain: &[u8], body: &[u8]) -> Result, IdentityError> { + let capacity = domain + .len() + .checked_add(1) + .and_then(|length| length.checked_add(body.len())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "social attestation signing bytes", + })?; + let mut message = Vec::with_capacity(capacity); + message.extend_from_slice(domain); + message.push(0); + message.extend_from_slice(body); + Ok(message) +} diff --git a/protocols/krikos-identity/src/state.rs b/protocols/krikos-identity/src/state.rs new file mode 100644 index 00000000000..0e29aadb744 --- /dev/null +++ b/protocols/krikos-identity/src/state.rs @@ -0,0 +1,2851 @@ +//! Pure deterministic account-state projection. + +use serde::Serialize; + +use crate::{ + AccountGenesis, AccountId, AccountOperation, AdmissionEvidence, AdmissionEvidenceId, + AlgorithmPublicKey, AuthorizedEvent, BlindedMetadataCommitment, CanonicalWire, CapabilityGrant, + ControlPolicy, ControlPolicyId, ControllerDescriptor, ControllerId, ControllerKeyId, + CryptoMigrationBody, CryptoSuiteDescriptor, CryptoSuiteId, DeviceClass, DeviceDescriptor, + DeviceId, Epoch, EventId, ForkCommonAncestor, FreshnessRequirement, GenesisAnchor, + IdentityError, ProposalId, ProtocolMajor, ProtocolUpgrade, ProviderPolicy, ProviderPolicyId, + ProviderQuorum, ProviderReceipts, RecoveryId, RecoveryPolicy, RecoveryPolicyId, + RecoveryProposal, RetireAccount, Sequence, SigningPublicKey, Timestamp, + limits::{ + MAX_CONTROLLERS, MAX_DEVICES, MAX_FORK_EVIDENCE_BYTES, MAX_FORK_HEADS, + MAX_HISTORY_PAGE_BYTES, MAX_HISTORY_PAGE_EVENTS, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PendingRecoveryObservation { + admission_evidence_id: AdmissionEvidenceId, + provider_policy_id: ProviderPolicyId, + required_quorum: ProviderQuorum, + observed_at: Timestamp, + delay_deadline: Timestamp, + lifetime_deadline: Timestamp, + receipts: ProviderReceipts, +} + +impl PendingRecoveryObservation { + fn from_admission( + evidence: &AdmissionEvidence, + recovery_policy: &RecoveryPolicy, + ) -> Result { + let delay = evidence.delay(); + let provider_policy_id = delay + .provider_policy_id() + .ok_or(IdentityError::FreshnessUnavailable)?; + let required_quorum = delay + .required_quorum() + .ok_or(IdentityError::FreshnessUnavailable)?; + let observed_at = delay + .observed_at() + .ok_or(IdentityError::FreshnessUnavailable)?; + let receipts = delay + .provider_receipts() + .ok_or(IdentityError::FreshnessUnavailable)? + .clone(); + Ok(Self { + admission_evidence_id: evidence.admission_evidence_id()?, + provider_policy_id, + required_quorum, + observed_at, + delay_deadline: observed_at.checked_add(recovery_policy.delay())?, + lifetime_deadline: observed_at.checked_add(recovery_policy.lifetime())?, + receipts, + }) + } + + fn matches_completion_anchor(&self, anchor: &crate::RecoveryDelayAnchor) -> bool { + self.provider_policy_id == anchor.provider_policy_id() + && self.required_quorum == anchor.required_quorum() + && self.observed_at == anchor.observed_at() + && receipt_entries_match(&self.receipts, anchor.receipts()) + } +} + +fn receipt_entries_match(begin: &ProviderReceipts, completion: &ProviderReceipts) -> bool { + begin.as_slice().len() == completion.as_slice().len() + && begin + .as_slice() + .iter() + .zip(completion.as_slice()) + .all(|(begin, completion)| { + begin.provider_id() == completion.provider_id() + && begin.entry() == completion.entry() + && begin.leaf_index() == completion.leaf_index() + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VerificationKey { + pub(crate) crypto_suite_id: CryptoSuiteId, + pub(crate) controller_key_id: ControllerKeyId, + pub(crate) algorithm_code: u16, + pub(crate) public_key: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct PendingRecovery { + recovery_id: RecoveryId, + proposal: RecoveryProposal, + pre_recovery_control_policy_id: ControlPolicyId, + begin_event_id: EventId, + begin_proposal_id: ProposalId, + begin_observation: PendingRecoveryObservation, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct MigrationKey { + controller_id: ControllerId, + key: AlgorithmPublicKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RetiredCryptoSuite { + suite_id: CryptoSuiteId, + retired_at: Epoch, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct StableCrypto { + suite: CryptoSuiteDescriptor, + migrated_keys: Vec, + retired_suites: Vec, + key_tombstones: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +enum CryptoProjection { + Stable(StableCrypto), + Candidate { + previous: StableCrypto, + migration: CryptoMigrationBody, + begin_event_id: EventId, + }, + Dual { + previous: StableCrypto, + migration: CryptoMigrationBody, + begin_event_id: EventId, + activation_event_id: EventId, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +enum RetirementProjection { + Account(RetireAccount), + CryptoMigration { + migration_id: crate::CryptoMigrationId, + successor_account_id: AccountId, + retired_at: Epoch, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LineageEntry { + pre_state: Box, + authority_state: Option>, + expected_epoch: Epoch, + event: AuthorizedEvent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ForkBranch { + transitions: Vec, + projected_state: AccountState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ForkProjection { + common_state: Box, + common_ancestor: ForkCommonAncestor, + conflict_sequence: Sequence, + conflict_predecessors: crate::EventPredecessors, + branches: Vec, +} + +/// Projection lifecycle, including the unresolved-fork state that cannot be checkpointed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectionLifecycle { + /// Ordinary active account authority. + Active, + /// One authoritative recovery is pending. + RecoveryPending, + /// Multiple valid control-event branches are retained without a selected winner. + Forked, + /// A candidate controller-signature suite is staged. + MigrationPending, + /// Both old and candidate controller-signature suites are required. + MigrationDual, + /// A future protocol major was authorized and this v1 implementation is read-only. + UpgradePending, + /// Terminal account retirement. + Retired, +} + +/// Immutable controller projection entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProjectedController { + id: ControllerId, + descriptor: ControllerDescriptor, +} + +impl ProjectedController { + /// Stable controller identifier. + pub const fn id(&self) -> ControllerId { + self.id + } + + /// Controller descriptor active at this projection revision. + pub const fn descriptor(&self) -> &ControllerDescriptor { + &self.descriptor + } + + /// Active controller signing key. + pub const fn signing_key(&self) -> SigningPublicKey { + self.descriptor.signing_key() + } +} + +/// Device lifecycle retained by the projection, including permanent tombstones. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum ProjectedDeviceLifecycle { + /// Device is authorized. + Active, + /// Device is temporarily disabled. + Suspended, + /// Device identifier is permanently revoked. + Revoked, +} + +/// Bounded projected device entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProjectedDevice { + id: DeviceId, + descriptor: DeviceDescriptor, + device_class: DeviceClass, + metadata_commitment: Option, + capabilities: Vec, + authorization_epoch: Epoch, + lifecycle: ProjectedDeviceLifecycle, +} + +impl ProjectedDevice { + /// Stable device identifier. + pub const fn id(&self) -> DeviceId { + self.id + } + + /// Current device lifecycle. + pub const fn lifecycle(&self) -> ProjectedDeviceLifecycle { + self.lifecycle + } + + /// Independently keyed public device descriptor. + pub const fn descriptor(&self) -> &DeviceDescriptor { + &self.descriptor + } + + /// Device authorization class. + pub const fn device_class(&self) -> DeviceClass { + self.device_class + } + + /// Current blinded private-metadata commitment. + pub const fn metadata_commitment(&self) -> Option { + self.metadata_commitment + } + + /// Current sorted capability grants. + pub fn capabilities(&self) -> &[CapabilityGrant] { + &self.capabilities + } + + /// Epoch at which the current authorization became valid. + pub const fn authorization_epoch(&self) -> Epoch { + self.authorization_epoch + } +} + +/// Stable disposition of one projection input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyDisposition { + /// A new linear transition was applied. + Applied, + /// The identical admitted event was already projected. + Replay, + /// Additional approvals for the same body and admission evidence were retained. + ApprovalsMerged, + /// A distinct valid event identity sharing the same predecessor was retained as fork evidence. + ForkDetected, +} + +/// Deterministic, idempotently keyed work requested by a successful transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectionEffect { + /// Publish the exact authorized event. + PublishAccountEvent { + /// Stable body-and-admission event key. + event_id: EventId, + }, + /// Rotate protected application group keys after an epoch-changing transition. + RotateGroupKeys { + /// Stable transition key. + event_id: EventId, + /// New account epoch for recipient selection. + epoch: Epoch, + }, + /// Notify local consumers of a projected account change. + NotifyAccountChanged { + /// Stable transition key. + event_id: EventId, + }, + /// Notify local consumers that multiple control branches are retained. + NotifyForkDetected { + /// Stable key of the newly observed branch. + event_id: EventId, + }, +} + +/// Owned compare-and-swap token covering one account and its complete sorted head set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountRevision { + account_id: AccountId, + heads: Vec, +} + +impl AccountRevision { + pub(crate) fn from_frozen_heads( + account_id: AccountId, + heads: Vec, + ) -> Result { + if heads.len() > crate::limits::MAX_FORK_HEADS { + return Err(IdentityError::limit( + "account revision heads", + heads.len(), + crate::limits::MAX_FORK_HEADS, + )); + } + for pair in heads.windows(2) { + if pair[0] == pair[1] { + return Err(IdentityError::DuplicateElement { + resource: "account revision heads", + }); + } + if pair[0] > pair[1] { + return Err(IdentityError::NonCanonical); + } + } + Ok(Self { account_id, heads }) + } + + /// Stable account whose revision is named. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Complete sorted head set used by atomic state-store compare-and-swap. + pub fn heads(&self) -> &[EventId] { + &self.heads + } +} + +/// Pure result of one successful projection attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApplyOutcome { + disposition: ApplyDisposition, + event_id: EventId, + effects: Vec, +} + +impl ApplyOutcome { + /// Whether the input applied, replayed, merged approvals, or opened a fork. + pub const fn disposition(&self) -> ApplyDisposition { + self.disposition + } + + /// Stable ID of the supplied admitted event. + pub const fn event_id(&self) -> EventId { + self.event_id + } + + /// Bounded deterministic effects; executing them is outside the projection. + pub fn effects(&self) -> &[ProjectionEffect] { + &self.effects + } +} + +/// Deterministic, bounded projection of one account's authoritative state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountState { + account_id: AccountId, + genesis_anchor: GenesisAnchor, + protocol_major: ProtocolMajor, + sequence: Sequence, + epoch: Epoch, + heads: Vec, + active_controllers: Vec, + revoked_controllers: Vec, + devices: Vec, + control_policy: ControlPolicy, + control_policy_id: ControlPolicyId, + recovery_policy: RecoveryPolicy, + recovery_policy_id: RecoveryPolicyId, + provider_policy: ProviderPolicy, + provider_policy_id: ProviderPolicyId, + pending_recovery: Option, + crypto: CryptoProjection, + upgrade: Option, + retirement: Option, + lifecycle: ProjectionLifecycle, + lineage: Vec, + lineage_bytes: usize, + historical_state_required_through: Option, + fork: Option, +} + +#[derive(Serialize)] +struct CanonicalProjectionView<'a> { + account_id: AccountId, + genesis_anchor: GenesisAnchor, + protocol_major: ProtocolMajor, + sequence: Sequence, + epoch: Epoch, + heads: &'a [EventId], + active_controllers: &'a [ProjectedController], + revoked_controllers: &'a [ProjectedController], + devices: &'a [ProjectedDevice], + control_policy: &'a ControlPolicy, + control_policy_id: ControlPolicyId, + recovery_policy: &'a RecoveryPolicy, + recovery_policy_id: RecoveryPolicyId, + provider_policy: &'a ProviderPolicy, + provider_policy_id: ProviderPolicyId, + pending_recovery: &'a Option, + crypto: &'a CryptoProjection, + upgrade: &'a Option, + retirement: &'a Option, + lifecycle_code: u16, +} + +#[derive(Serialize)] +struct StableCryptoStateMaterial<'a> { + current_suite_id: CryptoSuiteId, + migrated_keys: &'a [MigrationKey], + retired_suites: &'a [RetiredCryptoSuite], + key_tombstones: &'a [AlgorithmPublicKey], +} + +#[derive(Serialize)] +enum CryptoStateMaterial<'a> { + Stable(StableCryptoStateMaterial<'a>), + Candidate { + previous: StableCryptoStateMaterial<'a>, + migration_id: crate::CryptoMigrationId, + candidate_suite_id: CryptoSuiteId, + begin_event_id: EventId, + }, + Dual { + previous: StableCryptoStateMaterial<'a>, + migration_id: crate::CryptoMigrationId, + candidate_suite_id: CryptoSuiteId, + begin_event_id: EventId, + activation_event_id: EventId, + }, +} + +#[derive(Serialize)] +struct CheckpointStateMaterial<'a> { + account_id: AccountId, + genesis_anchor: GenesisAnchor, + protocol_major: ProtocolMajor, + sequence: Sequence, + epoch: Epoch, + heads: &'a [EventId], + control_policy_id: ControlPolicyId, + recovery_policy_id: RecoveryPolicyId, + provider_policy_id: ProviderPolicyId, + pending_recovery: &'a Option, + crypto_state_id: crate::CryptoStateId, + lifecycle_code: u16, + upgrade: &'a Option, + retirement: &'a Option, +} + +impl AccountState { + /// Project the canonical genesis object without clocks, storage, or I/O. + pub fn from_genesis(genesis: &AccountGenesis) -> Result { + let active_controllers = genesis + .initial_controllers() + .iter() + .map(|descriptor| { + Ok(ProjectedController { + id: descriptor.id()?, + descriptor: descriptor.clone(), + }) + }) + .collect::, IdentityError>>()?; + Ok(Self { + account_id: genesis.account_id()?, + genesis_anchor: genesis.genesis_anchor()?, + protocol_major: ProtocolMajor::new(1)?, + sequence: Sequence::GENESIS, + epoch: Epoch::GENESIS, + heads: Vec::new(), + active_controllers, + revoked_controllers: Vec::new(), + devices: Vec::new(), + control_policy: genesis.initial_policy().clone(), + control_policy_id: genesis.initial_policy().id()?, + recovery_policy: genesis.initial_recovery_policy().clone(), + recovery_policy_id: genesis.initial_recovery_policy().id()?, + provider_policy: genesis.initial_provider_policy().clone(), + provider_policy_id: genesis.initial_provider_policy().id()?, + pending_recovery: None, + crypto: CryptoProjection::Stable(StableCrypto { + suite: CryptoSuiteDescriptor::v1()?, + migrated_keys: Vec::new(), + retired_suites: Vec::new(), + key_tombstones: Vec::new(), + }), + upgrade: None, + retirement: None, + lifecycle: ProjectionLifecycle::Active, + lineage: Vec::new(), + lineage_bytes: 0, + historical_state_required_through: None, + fork: None, + }) + } + + /// Validate and atomically apply one authorized event. + pub fn validate_and_apply( + &mut self, + event: &AuthorizedEvent, + ) -> Result { + let mut staged = self.clone(); + let outcome = staged.validate_and_apply_inner(event)?; + *self = staged; + Ok(outcome) + } + + /// Apply a possible conflict whose pre-state was evicted from the bounded memory cache. + /// + /// `accepted_event` and `historical_pre_state` must come from the account's authenticated + /// durable lineage. This method revalidates both the accepted and incoming bodies from that + /// exact pre-state before opening a fork; storage integration remains responsible for proving + /// that the accepted event belongs to the durable lineage selected by this projection. + pub(crate) fn validate_and_apply_historical_conflict( + &mut self, + historical_pre_state: &AccountState, + accepted_path: &[AuthorizedEvent], + incoming_event: &AuthorizedEvent, + ) -> Result { + let accepted_event = accepted_path + .first() + .ok_or(IdentityError::StorageCorruption)?; + let sequence = incoming_event.body().sequence(); + if self + .historical_state_required_through + .is_none_or(|through| sequence > through) + { + return Err(IdentityError::InvalidRelationship { + resource: "historical conflict cache boundary", + }); + } + if historical_pre_state.account_id() != self.account_id + || historical_pre_state.genesis_anchor() != self.genesis_anchor + { + return Err(IdentityError::AccountMismatch); + } + if accepted_event.body().sequence() != sequence + || accepted_event.body().predecessors() != incoming_event.body().predecessors() + || accepted_event.event_id()? == incoming_event.event_id()? + { + return Err(IdentityError::InvalidRelationship { + resource: "historical conflict event pair", + }); + } + let accepted_expected_epoch = + expected_epoch(historical_pre_state, accepted_event.body().operation())?; + crate::verifier::validate_event( + historical_pre_state, + historical_pre_state, + accepted_event, + accepted_expected_epoch, + )?; + let incoming_epoch = + expected_epoch(historical_pre_state, incoming_event.body().operation())?; + crate::verifier::validate_event( + historical_pre_state, + historical_pre_state, + incoming_event, + incoming_epoch, + )?; + + let mut accepted_projection = historical_pre_state.detached_snapshot(); + let mut accepted_transitions = Vec::new(); + let mut accepted_path_bytes = 0_usize; + for accepted in accepted_path { + let pre_state = accepted_projection.detached_snapshot(); + let accepted_epoch = expected_epoch(&pre_state, accepted.body().operation())?; + crate::verifier::validate_event(&pre_state, &pre_state, accepted, accepted_epoch)?; + accepted_projection.apply_new_linear(accepted, accepted.event_id()?)?; + let transition = LineageEntry { + pre_state: Box::new(pre_state), + authority_state: None, + expected_epoch: accepted_epoch, + event: accepted.clone(), + }; + checked_evidence_add( + &mut accepted_path_bytes, + lineage_entry_evidence_bytes(&transition)?, + )?; + if accepted_path_bytes > MAX_FORK_EVIDENCE_BYTES { + return Err(IdentityError::limit( + "account fork evidence bytes", + accepted_path_bytes, + MAX_FORK_EVIDENCE_BYTES, + )); + } + accepted_transitions.push(transition); + } + let mut authenticated_tip = accepted_projection.detached_snapshot(); + authenticated_tip.historical_state_required_through = None; + let mut current_tip = self.detached_snapshot(); + current_tip.historical_state_required_through = None; + if authenticated_tip != current_tip { + return Err(IdentityError::StorageCorruption); + } + + let mut staged = self.clone(); + let outcome = staged.open_fork( + incoming_event, + incoming_event.event_id()?, + LineageEntry { + pre_state: Box::new(historical_pre_state.detached_snapshot()), + authority_state: None, + expected_epoch: accepted_expected_epoch, + event: accepted_event.clone(), + }, + Some(accepted_transitions), + )?; + *self = staged; + Ok(outcome) + } + + /// Stable account identifier. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Genesis predecessor anchor. + pub const fn genesis_anchor(&self) -> GenesisAnchor { + self.genesis_anchor + } + + /// Projected account-event sequence. + pub const fn sequence(&self) -> Sequence { + self.sequence + } + + /// Projected security epoch. + pub const fn epoch(&self) -> Epoch { + self.epoch + } + + /// Complete sorted current head set. + pub fn heads(&self) -> &[EventId] { + &self.heads + } + + /// Canonically sorted active controllers. + pub fn active_controllers(&self) -> &[ProjectedController] { + &self.active_controllers + } + + /// Canonically sorted permanently revoked controller tombstones. + pub fn revoked_controllers(&self) -> &[ProjectedController] { + &self.revoked_controllers + } + + /// Canonically sorted device entries, including tombstones. + pub fn devices(&self) -> &[ProjectedDevice] { + &self.devices + } + + /// Current control policy. + pub const fn control_policy(&self) -> &ControlPolicy { + &self.control_policy + } + + /// Current canonical control-policy ID. + pub const fn control_policy_id(&self) -> ControlPolicyId { + self.control_policy_id + } + + /// Current recovery policy. + pub const fn recovery_policy(&self) -> &RecoveryPolicy { + &self.recovery_policy + } + + /// Current canonical recovery-policy ID. + pub const fn recovery_policy_id(&self) -> RecoveryPolicyId { + self.recovery_policy_id + } + + /// Current provider policy. + pub const fn provider_policy(&self) -> &ProviderPolicy { + &self.provider_policy + } + + /// Current canonical provider-policy ID. + pub const fn provider_policy_id(&self) -> ProviderPolicyId { + self.provider_policy_id + } + + /// Current projection lifecycle. + pub const fn lifecycle(&self) -> ProjectionLifecycle { + self.lifecycle + } + + /// Return an owned deterministic revision token for atomic store compare-and-swap. + pub fn revision_token(&self) -> AccountRevision { + AccountRevision { + account_id: self.account_id, + heads: self.heads.clone(), + } + } + + /// Domain-separated commitment to the complete projected cryptographic state. + pub(crate) fn crypto_state_id(&self) -> Result { + let material = match &self.crypto { + CryptoProjection::Stable(stable) => { + CryptoStateMaterial::Stable(stable_crypto_state_material(stable)?) + } + CryptoProjection::Candidate { + previous, + migration, + begin_event_id, + } => CryptoStateMaterial::Candidate { + previous: stable_crypto_state_material(previous)?, + migration_id: migration.crypto_migration_id()?, + candidate_suite_id: migration.to_suite().crypto_suite_id()?, + begin_event_id: *begin_event_id, + }, + CryptoProjection::Dual { + previous, + migration, + begin_event_id, + activation_event_id, + } => CryptoStateMaterial::Dual { + previous: stable_crypto_state_material(previous)?, + migration_id: migration.crypto_migration_id()?, + candidate_suite_id: migration.to_suite().crypto_suite_id()?, + begin_event_id: *begin_event_id, + activation_event_id: *activation_event_id, + }, + }; + let bytes = crate::codec::encode_wire(&material)?; + Ok(crate::CryptoStateId::from_digest(crate::types::hash_bytes( + crate::types::HashDomain::CryptoState, + &bytes, + ))) + } + + /// Canonical cache-free material committed by checkpoint state leaves. + pub(crate) fn checkpoint_state_material(&self) -> Result, IdentityError> { + if self.fork.is_some() || self.lifecycle == ProjectionLifecycle::Forked { + return Err(IdentityError::AccountForked); + } + crate::codec::encode_wire(&CheckpointStateMaterial { + account_id: self.account_id, + genesis_anchor: self.genesis_anchor, + protocol_major: self.protocol_major, + sequence: self.sequence, + epoch: self.epoch, + heads: &self.heads, + control_policy_id: self.control_policy_id, + recovery_policy_id: self.recovery_policy_id, + provider_policy_id: self.provider_policy_id, + pending_recovery: &self.pending_recovery, + crypto_state_id: self.crypto_state_id()?, + lifecycle_code: projection_lifecycle_code(self.lifecycle), + upgrade: &self.upgrade, + retirement: &self.retirement, + }) + } + + /// Frozen v1 epoch rule by operation kind; `None` means code 20 depends on its mode. + pub const fn operation_kind_advances_epoch(kind: crate::OperationKind) -> Option { + match kind { + crate::OperationKind::UpdateDeviceMetadata + | crate::OperationKind::BeginCryptoMigration => Some(false), + crate::OperationKind::RetireCryptoSuite => None, + crate::OperationKind::AuthorizeDevice + | crate::OperationKind::UpdateDeviceAuthorization + | crate::OperationKind::SuspendDevice + | crate::OperationKind::ReinstateDevice + | crate::OperationKind::RevokeDevice + | crate::OperationKind::RotateDeviceKeys + | crate::OperationKind::AddController + | crate::OperationKind::RemoveController + | crate::OperationKind::ChangeControlPolicy + | crate::OperationKind::ChangeRecoveryPolicy + | crate::OperationKind::ChangeProviderPolicy + | crate::OperationKind::BeginRecovery + | crate::OperationKind::VetoRecovery + | crate::OperationKind::CancelRecovery + | crate::OperationKind::FinalizeRecovery + | crate::OperationKind::ResolveFork + | crate::OperationKind::ActivateCryptoMigration + | crate::OperationKind::UpgradeProtocol + | crate::OperationKind::RetireAccount => Some(true), + } + } + + /// Exact resulting epoch required for an operation in this pre-state. + pub fn expected_epoch_for(&self, operation: &AccountOperation) -> Result { + expected_epoch(self, operation) + } + + fn validate_and_apply_inner( + &mut self, + event: &AuthorizedEvent, + ) -> Result { + let event_id = event.event_id()?; + if let Some(outcome) = self.try_replay(event, event_id)? { + return Ok(outcome); + } + if self.fork.is_some() { + return self.apply_while_forked(event, event_id); + } + if let Some(conflict) = self.find_lineage_conflict(event, event_id)? { + return self.open_fork(event, event_id, conflict, None); + } + if event.body().account_id() == self.account_id + && self + .historical_state_required_through + .is_some_and(|through| event.body().sequence() <= through) + { + return Err(IdentityError::HistoricalStateRequired { + sequence: event.body().sequence().get(), + }); + } + self.apply_new_linear(event, event_id) + } + + fn try_replay( + &mut self, + event: &AuthorizedEvent, + event_id: EventId, + ) -> Result, IdentityError> { + if let Some(index) = self + .lineage + .iter() + .position(|entry| entry.event.event_id() == Ok(event_id)) + { + let changed = { + let entry = &mut self.lineage[index]; + crate::verifier::validate_event( + &entry.pre_state, + entry.authority_state.as_deref().unwrap_or(&entry.pre_state), + event, + entry.expected_epoch, + )?; + let merged = merge_event_evidence(&entry.event, event)?; + if merged != entry.event { + entry.event = merged; + true + } else { + false + } + }; + let disposition = if changed { + self.normalize_lineage_bound()?; + ApplyDisposition::ApprovalsMerged + } else { + ApplyDisposition::Replay + }; + return Ok(Some(ApplyOutcome { + disposition, + event_id, + effects: Vec::new(), + })); + } + + let fork_transitions = self.fork.as_ref().map_or_else(Vec::new, |fork| { + fork.branches + .iter() + .enumerate() + .flat_map(|(branch_index, branch)| { + branch.transitions.iter().enumerate().filter_map( + move |(transition_index, transition)| { + (transition.event.event_id() == Ok(event_id)) + .then_some((branch_index, transition_index)) + }, + ) + }) + .collect::>() + }); + if !fork_transitions.is_empty() { + let fork = self.fork.as_ref().ok_or(IdentityError::StorageCorruption)?; + let mut merged = event.clone(); + for (branch_index, transition_index) in &fork_transitions { + let transition = &fork.branches[*branch_index].transitions[*transition_index]; + crate::verifier::validate_event( + &transition.pre_state, + transition + .authority_state + .as_deref() + .unwrap_or(&transition.pre_state), + event, + transition.expected_epoch, + )?; + merged = merge_event_evidence(&transition.event, &merged)?; + } + let fork = self.fork.as_mut().ok_or(IdentityError::StorageCorruption)?; + let changed = fork_transitions + .iter() + .any(|(branch_index, transition_index)| { + fork.branches[*branch_index].transitions[*transition_index].event != merged + }); + let disposition = if changed { + for (branch_index, transition_index) in fork_transitions { + fork.branches[branch_index].transitions[transition_index].event = + merged.clone(); + } + validate_fork_evidence_bound(&fork.common_state, &fork.branches)?; + ApplyDisposition::ApprovalsMerged + } else { + ApplyDisposition::Replay + }; + return Ok(Some(ApplyOutcome { + disposition, + event_id, + effects: Vec::new(), + })); + } + Ok(None) + } + + fn find_lineage_conflict( + &self, + event: &AuthorizedEvent, + event_id: EventId, + ) -> Result, IdentityError> { + for entry in self.lineage.iter().rev() { + if entry.event.event_id()? != event_id + && entry.event.body().account_id() == event.body().account_id() + && entry.event.body().sequence() == event.body().sequence() + && entry.event.body().predecessors() == event.body().predecessors() + { + return Ok(Some(entry.clone())); + } + } + Ok(None) + } + + fn apply_new_linear( + &mut self, + event: &AuthorizedEvent, + event_id: EventId, + ) -> Result { + self.validate_lifecycle_gate(event.body().operation())?; + let expected_epoch = expected_epoch(self, event.body().operation())?; + let validated = crate::verifier::validate_event(self, self, event, expected_epoch)?; + + let pre_state = self.detached_snapshot(); + self.apply_operation(event, event_id, validated.provider_authority_time())?; + self.sequence = event.body().sequence(); + self.epoch = event.body().resulting_epoch(); + self.heads.clear(); + self.heads.push(event_id); + self.retain_lineage_entry(LineageEntry { + pre_state: Box::new(pre_state.clone()), + authority_state: None, + expected_epoch, + event: event.clone(), + })?; + self.fork = None; + + Ok(ApplyOutcome { + disposition: ApplyDisposition::Applied, + event_id, + effects: transition_effects( + event_id, + self.epoch, + operation_changes_epoch(event.body().operation()), + ), + }) + } + + fn open_fork( + &mut self, + event: &AuthorizedEvent, + event_id: EventId, + conflict: LineageEntry, + authenticated_left_path: Option>, + ) -> Result { + if conflict.pre_state.fork.is_some() + && matches!(event.body().operation(), AccountOperation::ResolveFork(_)) + { + return self.open_resolution_fork(event, event_id, &conflict); + } + let common_state = conflict.pre_state.detached_snapshot(); + let expected_epoch = expected_epoch(&common_state, event.body().operation())?; + crate::verifier::validate_event(&common_state, &common_state, event, expected_epoch)?; + + let left_state = self.detached_snapshot(); + let conflict_id = conflict.event.event_id()?; + let left_start = self + .lineage + .iter() + .position(|transition| transition.event.event_id() == Ok(conflict_id)); + let left_transitions = match authenticated_left_path { + Some(path) => path, + None => { + let index = left_start.ok_or(IdentityError::HistoricalStateRequired { + sequence: conflict.event.body().sequence().get(), + })?; + self.lineage[index..].to_vec() + } + }; + let mut right_state = common_state.detached_snapshot(); + right_state.apply_new_linear(event, event_id)?; + let right_transition = right_state + .lineage + .last() + .ok_or(IdentityError::StorageCorruption)?; + let right_state = right_state.detached_snapshot(); + let mut branches = vec![ + ForkBranch { + transitions: left_transitions, + projected_state: left_state, + }, + ForkBranch { + transitions: vec![LineageEntry { + pre_state: right_transition.pre_state.clone(), + authority_state: right_transition.authority_state.clone(), + expected_epoch: right_transition.expected_epoch, + event: right_transition.event.clone(), + }], + projected_state: right_state, + }, + ]; + sort_fork_branches(&mut branches)?; + validate_fork_evidence_bound(&common_state, &branches)?; + + *self = common_state.detached_snapshot(); + self.sequence = branches + .iter() + .map(|branch| branch.projected_state.sequence()) + .max() + .ok_or(IdentityError::StorageCorruption)?; + self.heads = fork_head_ids(&branches)?; + self.lifecycle = ProjectionLifecycle::Forked; + let common_ancestor = if common_state.sequence() == Sequence::GENESIS { + ForkCommonAncestor::Genesis(common_state.genesis_anchor()) + } else { + let [ancestor] = common_state.heads() else { + return Err(IdentityError::StorageCorruption); + }; + ForkCommonAncestor::Event(*ancestor) + }; + self.fork = Some(ForkProjection { + common_state: Box::new(common_state), + common_ancestor, + conflict_sequence: event.body().sequence(), + conflict_predecessors: event.body().predecessors().clone(), + branches, + }); + + Ok(ApplyOutcome { + disposition: ApplyDisposition::ForkDetected, + event_id, + effects: vec![ + ProjectionEffect::PublishAccountEvent { event_id }, + ProjectionEffect::NotifyForkDetected { event_id }, + ], + }) + } + + fn open_resolution_fork( + &mut self, + event: &AuthorizedEvent, + event_id: EventId, + conflict: &LineageEntry, + ) -> Result { + let unresolved_pre_state = conflict.pre_state.as_ref(); + let original_fork = unresolved_pre_state + .fork + .as_ref() + .ok_or(IdentityError::StorageCorruption)?; + let common_state = original_fork.common_state.detached_snapshot(); + let left_state = self.detached_snapshot(); + let conflict_id = conflict.event.event_id()?; + let left_start = self + .lineage + .iter() + .position(|transition| transition.event.event_id() == Ok(conflict_id)) + .ok_or(IdentityError::StorageCorruption)?; + let left_transitions = self.lineage[left_start..].to_vec(); + let mut right_state = unresolved_pre_state.clone(); + right_state.apply_fork_resolution(event, event_id)?; + let right_transition = right_state + .lineage + .last() + .ok_or(IdentityError::StorageCorruption)?; + let right_state = right_state.detached_snapshot(); + let mut branches = vec![ + ForkBranch { + transitions: left_transitions, + projected_state: left_state, + }, + ForkBranch { + transitions: vec![LineageEntry { + pre_state: right_transition.pre_state.clone(), + authority_state: right_transition.authority_state.clone(), + expected_epoch: right_transition.expected_epoch, + event: right_transition.event.clone(), + }], + projected_state: right_state, + }, + ]; + sort_fork_branches(&mut branches)?; + validate_fork_evidence_bound(&common_state, &branches)?; + + *self = common_state.detached_snapshot(); + self.sequence = branches + .iter() + .map(|branch| branch.projected_state.sequence()) + .max() + .ok_or(IdentityError::StorageCorruption)?; + self.heads = fork_head_ids(&branches)?; + self.lifecycle = ProjectionLifecycle::Forked; + self.fork = Some(ForkProjection { + common_state: Box::new(common_state), + common_ancestor: original_fork.common_ancestor, + conflict_sequence: event.body().sequence(), + conflict_predecessors: event.body().predecessors().clone(), + branches, + }); + Ok(ApplyOutcome { + disposition: ApplyDisposition::ForkDetected, + event_id, + effects: vec![ + ProjectionEffect::PublishAccountEvent { event_id }, + ProjectionEffect::NotifyForkDetected { event_id }, + ], + }) + } + + fn apply_while_forked( + &mut self, + event: &AuthorizedEvent, + event_id: EventId, + ) -> Result { + if matches!(event.body().operation(), AccountOperation::ResolveFork(_)) + && event.body().predecessors().event_heads() == Some(self.heads()) + { + return self.apply_fork_resolution(event, event_id); + } + + let mut fork = self + .fork + .as_ref() + .ok_or(IdentityError::StorageCorruption)? + .clone(); + if fork.branches.len() < 2 { + return Err(IdentityError::StorageCorruption); + } + + let parent_index = fork.branches.iter().position(|branch| { + branch.projected_state.sequence().checked_next().ok() == Some(event.body().sequence()) + && event.body().predecessors().event_heads() == Some(branch.projected_state.heads()) + }); + if let Some(parent_index) = parent_index { + let (transition, projected_state) = project_transition( + &fork.branches[parent_index].projected_state, + event, + event_id, + )?; + fork.branches[parent_index].transitions.push(transition); + fork.branches[parent_index].projected_state = projected_state; + } else { + let conflict = fork + .branches + .iter() + .enumerate() + .find_map(|(branch_index, branch)| { + branch + .transitions + .iter() + .enumerate() + .find(|(_, transition)| { + transition.event.body().sequence() == event.body().sequence() + && transition.event.body().predecessors() + == event.body().predecessors() + }) + .map(|(transition_index, transition)| { + (branch_index, transition_index, transition.clone()) + }) + }) + .ok_or(IdentityError::AccountForked)?; + if fork.branches.len() >= MAX_FORK_HEADS { + return Err(IdentityError::limit( + "account fork heads", + fork.branches.len().saturating_add(1), + MAX_FORK_HEADS, + )); + } + let (branch_index, transition_index, conflicting_transition) = conflict; + let (incoming_transition, projected_state) = + project_transition(&conflicting_transition.pre_state, event, event_id)?; + let mut transitions = + fork.branches[branch_index].transitions[..transition_index].to_vec(); + transitions.push(incoming_transition); + fork.branches.push(ForkBranch { + transitions, + projected_state, + }); + } + sort_fork_branches(&mut fork.branches)?; + validate_fork_evidence_bound(&fork.common_state, &fork.branches)?; + self.sequence = fork + .branches + .iter() + .map(|branch| branch.projected_state.sequence()) + .max() + .ok_or(IdentityError::StorageCorruption)?; + self.heads = fork_head_ids(&fork.branches)?; + self.fork = Some(fork); + Ok(ApplyOutcome { + disposition: ApplyDisposition::ForkDetected, + event_id, + effects: vec![ + ProjectionEffect::PublishAccountEvent { event_id }, + ProjectionEffect::NotifyForkDetected { event_id }, + ], + }) + } + + fn detached_snapshot(&self) -> Self { + let mut snapshot = self.clone(); + snapshot.lineage.clear(); + snapshot.lineage_bytes = 0; + snapshot.fork = None; + snapshot + } + + fn retain_lineage_entry(&mut self, entry: LineageEntry) -> Result<(), IdentityError> { + let entry_bytes = lineage_entry_evidence_bytes(&entry)?; + while !self.lineage.is_empty() + && (self.lineage.len() >= MAX_HISTORY_PAGE_EVENTS + || self.lineage_bytes.checked_add(entry_bytes).ok_or( + IdentityError::ArithmeticOverflow { + resource: "account lineage bytes", + }, + )? > MAX_HISTORY_PAGE_BYTES) + { + let removed = self.lineage.remove(0); + let removed_bytes = lineage_entry_evidence_bytes(&removed)?; + self.lineage_bytes = self + .lineage_bytes + .checked_sub(removed_bytes) + .ok_or(IdentityError::StorageCorruption)?; + self.historical_state_required_through = Some(removed.event.body().sequence()); + } + if entry_bytes > MAX_HISTORY_PAGE_BYTES { + self.historical_state_required_through = Some(entry.event.body().sequence()); + return Ok(()); + } + self.lineage_bytes = self.lineage_bytes.checked_add(entry_bytes).ok_or( + IdentityError::ArithmeticOverflow { + resource: "account lineage bytes", + }, + )?; + self.lineage.push(entry); + Ok(()) + } + + fn normalize_lineage_bound(&mut self) -> Result<(), IdentityError> { + self.lineage_bytes = 0; + let entries = std::mem::take(&mut self.lineage); + for entry in entries { + self.retain_lineage_entry(entry)?; + } + Ok(()) + } + + fn validate_lifecycle_gate(&self, operation: &AccountOperation) -> Result<(), IdentityError> { + match self.lifecycle { + ProjectionLifecycle::Active => Ok(()), + ProjectionLifecycle::RecoveryPending + if matches!( + operation, + AccountOperation::VetoRecovery(_) + | AccountOperation::CancelRecovery(_) + | AccountOperation::FinalizeRecovery(_) + ) => + { + Ok(()) + } + ProjectionLifecycle::RecoveryPending => Err(IdentityError::RecoveryPending), + ProjectionLifecycle::MigrationPending + if matches!( + operation, + AccountOperation::ActivateCryptoMigration(_) + | AccountOperation::RetireCryptoSuite(_) + ) => + { + Ok(()) + } + ProjectionLifecycle::MigrationDual + if matches!(operation, AccountOperation::RetireCryptoSuite(_)) => + { + Ok(()) + } + ProjectionLifecycle::MigrationPending | ProjectionLifecycle::MigrationDual => { + Err(IdentityError::InvalidRelationship { + resource: "cryptographic migration phase operation", + }) + } + ProjectionLifecycle::UpgradePending => Err(IdentityError::UnsupportedVersion { + version: self.protocol_major.get(), + }), + ProjectionLifecycle::Retired => Err(IdentityError::AccountRetired), + ProjectionLifecycle::Forked => Err(IdentityError::AccountForked), + } + } + + fn apply_operation( + &mut self, + event: &AuthorizedEvent, + event_id: EventId, + provider_authority_time: Option, + ) -> Result<(), IdentityError> { + match event.body().operation() { + AccountOperation::AuthorizeDevice(authorization) => { + self.authorize_device(authorization, event.body().resulting_epoch()) + } + AccountOperation::UpdateDeviceAuthorization(update) => { + self.update_device_authorization(update, event.body().resulting_epoch()) + } + AccountOperation::UpdateDeviceMetadata(update) => { + let device = self.device_mut(update.device_id())?; + device.metadata_commitment = update.metadata_commitment(); + Ok(()) + } + AccountOperation::SuspendDevice(suspend) => { + let device = self.device_mut(suspend.device_id())?; + match device.lifecycle { + ProjectedDeviceLifecycle::Active => { + device.lifecycle = ProjectedDeviceLifecycle::Suspended; + Ok(()) + } + ProjectedDeviceLifecycle::Suspended => Err(IdentityError::DeviceSuspended), + ProjectedDeviceLifecycle::Revoked => Err(IdentityError::DeviceRevoked), + } + } + AccountOperation::ReinstateDevice(reinstate) => { + let device = self.device_mut(reinstate.device_id())?; + match device.lifecycle { + ProjectedDeviceLifecycle::Suspended => { + device.lifecycle = ProjectedDeviceLifecycle::Active; + Ok(()) + } + ProjectedDeviceLifecycle::Active => Err(IdentityError::InvalidRelationship { + resource: "reinstate active device", + }), + ProjectedDeviceLifecycle::Revoked => Err(IdentityError::DeviceRevoked), + } + } + AccountOperation::RevokeDevice(revoke) => { + let device = self.device_mut(revoke.device_id())?; + if device.lifecycle == ProjectedDeviceLifecycle::Revoked { + return Err(IdentityError::DeviceRevoked); + } + device.lifecycle = ProjectedDeviceLifecycle::Revoked; + Ok(()) + } + AccountOperation::RotateDeviceKeys(rotation) => { + let old = self.device_mut(rotation.old_device_id())?; + if old.lifecycle == ProjectedDeviceLifecycle::Revoked { + return Err(IdentityError::DeviceRevoked); + } + old.lifecycle = ProjectedDeviceLifecycle::Revoked; + self.authorize_device(rotation.new_authorization(), event.body().resulting_epoch()) + } + AccountOperation::AddController(descriptor) => self.add_controller(descriptor), + AccountOperation::RemoveController(controller_id) => { + self.remove_controller(*controller_id) + } + AccountOperation::ChangeControlPolicy(policy) => { + policy.validate_satisfiable(&self.active_controller_descriptors())?; + self.control_policy_id = policy.id()?; + self.control_policy = policy.clone(); + Ok(()) + } + AccountOperation::ChangeRecoveryPolicy(policy) => { + if policy.policy_version() + != self.recovery_policy.policy_version().checked_next()? + { + return Err(IdentityError::PolicyVersionMismatch); + } + policy.validate_controller_authority(&self.active_controller_descriptors())?; + self.recovery_policy_id = policy.id()?; + self.recovery_policy = policy.clone(); + Ok(()) + } + AccountOperation::ChangeProviderPolicy(policy) => { + if policy.policy_version() + != self.provider_policy.policy_version().checked_next()? + { + return Err(IdentityError::PolicyVersionMismatch); + } + self.provider_policy_id = policy.id()?; + self.provider_policy = policy.clone(); + Ok(()) + } + AccountOperation::BeginRecovery(begin) => self.begin_recovery( + begin, + event.admission_evidence(), + event_id, + event.body().proposal_id()?, + ), + AccountOperation::VetoRecovery(veto) => self.veto_recovery(veto), + AccountOperation::CancelRecovery(cancel) => self.cancel_recovery(cancel), + AccountOperation::FinalizeRecovery(finalize) => { + self.finalize_recovery(finalize, provider_authority_time) + } + AccountOperation::ResolveFork(_) => Err(IdentityError::InvalidPredecessor), + AccountOperation::BeginCryptoMigration(begin) => { + self.begin_crypto_migration(begin, event_id) + } + AccountOperation::ActivateCryptoMigration(activate) => { + self.activate_crypto_migration(activate, event_id) + } + AccountOperation::RetireCryptoSuite(retire) => { + self.retire_crypto_suite(retire, event.body().resulting_epoch()) + } + AccountOperation::UpgradeProtocol(upgrade) => { + if upgrade.from_major() != self.protocol_major { + return Err(IdentityError::UnsupportedVersion { + version: upgrade.from_major().get(), + }); + } + self.protocol_major = upgrade.to_major(); + self.upgrade = Some(upgrade.clone()); + self.lifecycle = ProjectionLifecycle::UpgradePending; + Ok(()) + } + AccountOperation::RetireAccount(retire) => { + self.retirement = Some(RetirementProjection::Account(retire.clone())); + self.lifecycle = ProjectionLifecycle::Retired; + Ok(()) + } + } + } + + fn apply_fork_resolution( + &mut self, + event: &AuthorizedEvent, + event_id: EventId, + ) -> Result { + let AccountOperation::ResolveFork(resolution) = event.body().operation() else { + return Err(IdentityError::AccountForked); + }; + let fork = self + .fork + .as_ref() + .ok_or(IdentityError::StorageCorruption)? + .clone(); + if resolution.fork().account_id() != self.account_id { + return Err(IdentityError::AccountMismatch); + } + if resolution.fork().heads() != self.heads + || resolution.fork().common_ancestor() != fork.common_ancestor + { + return Err(IdentityError::InvalidPredecessor); + } + let selected = fork + .branches + .iter() + .find(|branch| fork_branch_event_id(branch) == Ok(resolution.selected_head())) + .ok_or(IdentityError::InvalidPredecessor)?; + let maximum_branch_epoch = fork + .branches + .iter() + .map(|branch| branch.projected_state.epoch()) + .max() + .ok_or(IdentityError::StorageCorruption)?; + let expected_epoch = maximum_branch_epoch.checked_next()?; + crate::verifier::validate_event(self, &fork.common_state, event, expected_epoch)?; + + let mut pre_state = self.clone(); + pre_state.lineage.clear(); + pre_state.lineage_bytes = 0; + let mut resolved = selected.projected_state.detached_snapshot(); + for controller_id in resolution.revoked_controllers() { + resolved.revoke_controller_for_resolution(*controller_id)?; + } + for device_id in resolution.revoked_devices() { + let device = resolved.device_mut(*device_id)?; + device.lifecycle = ProjectedDeviceLifecycle::Revoked; + } + if resolved.active_controllers.is_empty() { + return Err(IdentityError::UnsatisfiableThreshold); + } + let descriptors = resolved.active_controller_descriptors(); + resolved.control_policy.validate_satisfiable(&descriptors)?; + resolved + .recovery_policy + .validate_controller_authority(&descriptors)?; + resolved.sequence = event.body().sequence(); + resolved.epoch = event.body().resulting_epoch(); + resolved.heads = vec![event_id]; + resolved.retain_lineage_entry(LineageEntry { + pre_state: Box::new(pre_state), + authority_state: Some(Box::new(fork.common_state.detached_snapshot())), + expected_epoch, + event: event.clone(), + })?; + resolved.fork = None; + *self = resolved; + + Ok(ApplyOutcome { + disposition: ApplyDisposition::Applied, + event_id, + effects: transition_effects(event_id, self.epoch, true), + }) + } + + fn revoke_controller_for_resolution( + &mut self, + controller_id: ControllerId, + ) -> Result<(), IdentityError> { + if self.revoked_controller(controller_id).is_some() { + return Ok(()); + } + let index = self + .active_controllers + .binary_search_by_key(&controller_id, ProjectedController::id) + .map_err(|_| IdentityError::UnknownController)?; + let controller = self.active_controllers.remove(index); + let insert_at = match self + .revoked_controllers + .binary_search_by_key(&controller_id, ProjectedController::id) + { + Ok(_) => return Err(IdentityError::StorageCorruption), + Err(index) => index, + }; + self.revoked_controllers.insert(insert_at, controller); + Ok(()) + } + + fn authorize_device( + &mut self, + authorization: &crate::DeviceAuthorization, + resulting_epoch: Epoch, + ) -> Result<(), IdentityError> { + if authorization.authorization_epoch() != resulting_epoch { + return Err(IdentityError::InvalidEpoch); + } + let device_id = authorization.device_id(); + match self + .devices + .binary_search_by_key(&device_id, ProjectedDevice::id) + { + Ok(index) if self.devices[index].lifecycle == ProjectedDeviceLifecycle::Revoked => { + return Err(IdentityError::DeviceRevoked); + } + Ok(_) => { + return Err(IdentityError::InvalidRelationship { + resource: "duplicate active device authorization", + }); + } + Err(index) => { + self.validate_new_device_descriptor(authorization.descriptor())?; + if self.devices.len() >= MAX_DEVICES { + return Err(IdentityError::limit( + "projected devices", + self.devices.len().saturating_add(1), + MAX_DEVICES, + )); + } + self.devices.insert( + index, + ProjectedDevice { + id: device_id, + descriptor: authorization.descriptor().clone(), + device_class: authorization.device_class(), + metadata_commitment: authorization.metadata_commitment(), + capabilities: authorization.capabilities().to_vec(), + authorization_epoch: authorization.authorization_epoch(), + lifecycle: ProjectedDeviceLifecycle::Active, + }, + ); + } + } + Ok(()) + } + + fn validate_new_device_descriptor( + &self, + descriptor: &crate::DeviceDescriptor, + ) -> Result<(), IdentityError> { + if self + .devices + .iter() + .any(|device| device_descriptors_reuse_key(device.descriptor(), descriptor)) + { + return Err(IdentityError::InvalidRelationship { + resource: "retained device public-key reuse", + }); + } + if self + .active_controllers + .iter() + .chain(&self.revoked_controllers) + .any(|controller| { + device_descriptor_reuses_controller_key(descriptor, controller.signing_key()) + }) + { + return Err(IdentityError::InvalidRelationship { + resource: "controller/device public-key role separation", + }); + } + let application_key = descriptor.application_signing_key(); + let agreement_key = descriptor.agreement_key(); + let endpoint_key = descriptor.endpoint_key().as_signing_key(); + let descriptor_keys = [ + application_key.as_bytes().as_slice(), + agreement_key.as_bytes().as_slice(), + endpoint_key.as_bytes().as_slice(), + ]; + if descriptor_keys + .iter() + .any(|key| self.crypto_retains_key_material(key)) + { + return Err(IdentityError::InvalidRelationship { + resource: "device/cryptographic key tombstone separation", + }); + } + Ok(()) + } + + fn update_device_authorization( + &mut self, + update: &crate::DeviceAuthorizationUpdate, + resulting_epoch: Epoch, + ) -> Result<(), IdentityError> { + if update.authorization_epoch() != resulting_epoch { + return Err(IdentityError::InvalidEpoch); + } + let device = self.device_mut(update.device_id())?; + if device.lifecycle == ProjectedDeviceLifecycle::Revoked { + return Err(IdentityError::DeviceRevoked); + } + device.device_class = update.device_class(); + device.capabilities = update.capabilities().to_vec(); + device.authorization_epoch = update.authorization_epoch(); + Ok(()) + } + + fn device_mut(&mut self, device_id: DeviceId) -> Result<&mut ProjectedDevice, IdentityError> { + let index = self + .devices + .binary_search_by_key(&device_id, ProjectedDevice::id) + .map_err(|_| IdentityError::DeviceNotAuthorized)?; + Ok(&mut self.devices[index]) + } + + fn add_controller(&mut self, descriptor: &ControllerDescriptor) -> Result<(), IdentityError> { + let controller_id = descriptor.id()?; + if self.crypto_retains_key_material(descriptor.signing_key().as_bytes()) { + return Err(IdentityError::DuplicateSigningKey); + } + let migrated_key = match &self.crypto { + CryptoProjection::Stable(stable) + if stable.suite != CryptoSuiteDescriptor::v1()? + || !stable.migrated_keys.is_empty() => + { + let algorithm_code = stable.suite.signature_algorithm_code(); + if algorithm_code != crate::SignatureAlgorithm::Ed25519.code() { + return Err(IdentityError::UnsupportedPolicyFeature { + feature: "post-migration controller enrollment for non-Ed25519 suites", + }); + } + let key = AlgorithmPublicKey::new( + algorithm_code, + descriptor.signing_key().as_bytes().to_vec(), + )?; + if stable.migrated_keys.iter().any(|retained| { + retained.key.algorithm_code() == key.algorithm_code() + && retained.key.as_bytes() == key.as_bytes() + }) { + return Err(IdentityError::DuplicateSigningKey); + } + Some(key) + } + CryptoProjection::Stable(_) => None, + CryptoProjection::Candidate { .. } | CryptoProjection::Dual { .. } => { + return Err(IdentityError::InvalidRelationship { + resource: "controller enrollment cryptographic migration phase", + }); + } + }; + if self.revoked_controller(controller_id).is_some() { + return Err(IdentityError::RevokedController); + } + if self.active_controller(controller_id).is_some() { + return Err(IdentityError::InvalidRelationship { + resource: "duplicate active controller", + }); + } + if self + .active_controllers + .iter() + .chain(&self.revoked_controllers) + .any(|controller| controller.signing_key() == descriptor.signing_key()) + { + return Err(IdentityError::DuplicateSigningKey); + } + if self.devices.iter().any(|device| { + device_descriptor_reuses_controller_key(device.descriptor(), descriptor.signing_key()) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "controller/device public-key role separation", + }); + } + let retained_count = self + .active_controllers + .len() + .checked_add(self.revoked_controllers.len()) + .and_then(|value| value.checked_add(1)) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "projected controller count", + })?; + if retained_count > MAX_CONTROLLERS { + return Err(IdentityError::limit( + "projected controllers", + retained_count, + MAX_CONTROLLERS, + )); + } + let index = match self + .active_controllers + .binary_search_by_key(&controller_id, ProjectedController::id) + { + Ok(_) => return Err(IdentityError::StorageCorruption), + Err(index) => index, + }; + self.active_controllers.insert( + index, + ProjectedController { + id: controller_id, + descriptor: descriptor.clone(), + }, + ); + if let Some(key) = migrated_key { + let CryptoProjection::Stable(stable) = &mut self.crypto else { + return Err(IdentityError::StorageCorruption); + }; + let key_index = match stable + .migrated_keys + .binary_search_by_key(&controller_id, |retained| retained.controller_id) + { + Ok(_) => return Err(IdentityError::StorageCorruption), + Err(key_index) => key_index, + }; + stable + .migrated_keys + .insert(key_index, MigrationKey { controller_id, key }); + } + Ok(()) + } + + fn crypto_retains_key_material(&self, candidate: &[u8]) -> bool { + let stable_reuses = |stable: &StableCrypto| { + stable + .migrated_keys + .iter() + .any(|retained| retained.key.as_bytes() == candidate) + || stable + .key_tombstones + .iter() + .any(|retired| retired.as_bytes() == candidate) + }; + match &self.crypto { + CryptoProjection::Stable(stable) => stable_reuses(stable), + CryptoProjection::Candidate { + previous, + migration, + .. + } + | CryptoProjection::Dual { + previous, + migration, + .. + } => { + stable_reuses(previous) + || migration + .bindings() + .iter() + .any(|binding| binding.new_signing_key().as_bytes() == candidate) + } + } + } + + fn remove_controller(&mut self, controller_id: ControllerId) -> Result<(), IdentityError> { + if self.revoked_controller(controller_id).is_some() { + return Err(IdentityError::RevokedController); + } + let index = self + .active_controllers + .binary_search_by_key(&controller_id, ProjectedController::id) + .map_err(|_| IdentityError::UnknownController)?; + let removed = self.active_controllers.remove(index); + if self.active_controllers.is_empty() { + return Err(IdentityError::UnsatisfiableThreshold); + } + let descriptors = self.active_controller_descriptors(); + self.control_policy.validate_satisfiable(&descriptors)?; + self.recovery_policy + .validate_controller_authority(&descriptors)?; + let revoked_index = match self + .revoked_controllers + .binary_search_by_key(&controller_id, ProjectedController::id) + { + Ok(_) => return Err(IdentityError::StorageCorruption), + Err(index) => index, + }; + self.revoked_controllers.insert(revoked_index, removed); + // A migrated signing key remains retained under its revoked controller identifier. + // This is a permanent key tombstone and also keeps the sorted active-key projection + // stable for every controller that remains active. + Ok(()) + } + + fn active_controller_descriptors(&self) -> Vec { + self.active_controllers + .iter() + .map(|controller| controller.descriptor.clone()) + .collect() + } + + fn begin_recovery( + &mut self, + begin: &crate::BeginRecovery, + admission_evidence: &AdmissionEvidence, + event_id: EventId, + begin_proposal_id: ProposalId, + ) -> Result<(), IdentityError> { + self.require_v1_recovery_crypto()?; + if self.pending_recovery.is_some() || !begin.requires_vacant_recovery_slot() { + return Err(IdentityError::RecoveryPending); + } + let plan = begin.proposal().plan(); + let current_head = self + .heads + .first() + .copied() + .ok_or(IdentityError::InvalidPredecessor)?; + if plan.account_id() != self.account_id { + return Err(IdentityError::AccountMismatch); + } + if plan.prior_event_head() != current_head { + return Err(IdentityError::InvalidPredecessor); + } + if plan.recovery_policy_id() != self.recovery_policy_id + || plan.recovery_policy_version() != self.recovery_policy.policy_version() + || begin.threshold_evidence().recovery_policy_id() != self.recovery_policy_id + || begin.threshold_evidence().recovery_policy_version() + != self.recovery_policy.policy_version() + { + return Err(IdentityError::PolicyVersionMismatch); + } + self.pending_recovery = Some(PendingRecovery { + recovery_id: begin.recovery_id(), + proposal: begin.proposal().clone(), + pre_recovery_control_policy_id: self.control_policy_id, + begin_event_id: event_id, + begin_proposal_id, + begin_observation: PendingRecoveryObservation::from_admission( + admission_evidence, + &self.recovery_policy, + )?, + }); + self.lifecycle = ProjectionLifecycle::RecoveryPending; + Ok(()) + } + + fn veto_recovery(&mut self, veto: &crate::VetoRecovery) -> Result<(), IdentityError> { + let pending = self + .pending_recovery + .as_ref() + .ok_or(IdentityError::InvalidRelationship { + resource: "veto without pending recovery", + })?; + if veto.expected_pending_recovery() != pending.recovery_id { + return Err(IdentityError::InvalidRelationship { + resource: "veto pending recovery compare-and-set", + }); + } + if veto.pre_recovery_control_policy_id() != pending.pre_recovery_control_policy_id { + return Err(IdentityError::PolicyVersionMismatch); + } + self.pending_recovery = None; + self.lifecycle = ProjectionLifecycle::Active; + Ok(()) + } + + fn cancel_recovery(&mut self, cancel: &crate::CancelRecovery) -> Result<(), IdentityError> { + let pending = self + .pending_recovery + .as_ref() + .ok_or(IdentityError::InvalidRelationship { + resource: "cancel without pending recovery", + })?; + if cancel.expected_pending_recovery() != pending.recovery_id { + return Err(IdentityError::InvalidRelationship { + resource: "cancel pending recovery compare-and-set", + }); + } + if cancel.threshold_evidence().recovery_policy_id() != self.recovery_policy_id + || cancel.threshold_evidence().recovery_policy_version() + != self.recovery_policy.policy_version() + { + return Err(IdentityError::PolicyVersionMismatch); + } + self.pending_recovery = None; + self.lifecycle = ProjectionLifecycle::Active; + Ok(()) + } + + fn finalize_recovery( + &mut self, + finalize: &crate::FinalizeRecovery, + provider_authority_time: Option, + ) -> Result<(), IdentityError> { + self.require_v1_recovery_crypto()?; + let pending = self + .pending_recovery + .as_ref() + .ok_or(IdentityError::InvalidRelationship { + resource: "finalize without pending recovery", + })? + .clone(); + if let Some(begin_transition) = self + .lineage + .iter() + .find(|entry| entry.event.event_id() == Ok(pending.begin_event_id)) + && begin_transition + .event + .admission_evidence() + .admission_evidence_id()? + != pending.begin_observation.admission_evidence_id + { + return Err(IdentityError::StorageCorruption); + } + if finalize.expected_pending_recovery() != pending.recovery_id { + return Err(IdentityError::InvalidRelationship { + resource: "finalize pending recovery compare-and-set", + }); + } + let anchor = finalize.delay_anchor(); + if anchor.account_id() != self.account_id || anchor.recovery_id() != pending.recovery_id { + return Err(IdentityError::AccountMismatch); + } + if anchor.begin_proposal_id() != pending.begin_proposal_id { + return Err(IdentityError::InvalidRelationship { + resource: "recovery begin proposal delay anchor", + }); + } + if pending.begin_observation.provider_policy_id != self.provider_policy_id { + return Err(IdentityError::StorageCorruption); + } + if anchor.provider_policy_id() != self.provider_policy_id { + return Err(IdentityError::PolicyVersionMismatch); + } + let replicated_provider_policy = match self.provider_policy.mode() { + crate::ProviderMode::LocalOnly => return Err(IdentityError::FreshnessUnavailable), + crate::ProviderMode::Replicated(policy) => policy, + }; + let finalize_rule = self + .control_policy + .rule_for(crate::OperationKind::FinalizeRecovery) + .ok_or(IdentityError::AuthorizationDenied)?; + let freshness_quorum = match finalize_rule.freshness() { + FreshnessRequirement::LatestKnown => 0, + FreshnessRequirement::ProviderQuorum(requirement) => { + usize::from(requirement.required().get()) + } + }; + let minimum_required = usize::from(replicated_provider_policy.sufficient_threshold().get()) + .max(freshness_quorum); + if usize::from(anchor.required_quorum().get()) < minimum_required { + return Err(IdentityError::FreshnessUnavailable); + } + if !pending.begin_observation.matches_completion_anchor(anchor) { + return Err(IdentityError::InvalidRelationship { + resource: "recovery begin observation binding", + }); + } + let required = usize::from(anchor.required_quorum().get()); + let mut configured_receipts = Vec::new(); + for receipt in anchor.receipts().as_slice() { + if receipt.entry().account_id() != self.account_id { + return Err(IdentityError::AccountMismatch); + } + if receipt.entry().subject() + != crate::ProviderLogSubject::EventIntent(pending.begin_proposal_id) + { + return Err(IdentityError::InvalidRelationship { + resource: "recovery delay receipt subject", + }); + } + let Some(provider) = crate::verifier::configured_provider( + replicated_provider_policy.providers(), + receipt.provider_id(), + )? + else { + continue; + }; + receipt.verify(provider)?; + configured_receipts.push(receipt); + } + if configured_receipts.len() < required { + return Err(IdentityError::FreshnessUnavailable); + } + let mut configured_observations = configured_receipts + .iter() + .map(|receipt| receipt.entry().observed_at()) + .collect::>(); + configured_observations.sort_unstable(); + if configured_observations[required - 1] != anchor.observed_at() { + return Err(IdentityError::InvalidRelationship { + resource: "configured-provider recovery delay observation anchor", + }); + } + let delay_deadline = pending.begin_observation.delay_deadline; + let mut completion_times = configured_receipts + .iter() + .map(|receipt| receipt.signed_head().body().observed_at()) + .filter(|observed_at| *observed_at >= delay_deadline) + .collect::>(); + if completion_times.len() < required { + return Err(IdentityError::DelayNotElapsed); + } + completion_times.sort_unstable(); + let nested_authority_time = completion_times[required - 1]; + let provider_authority_time = provider_authority_time + .map_or(nested_authority_time, |outer| { + outer.max(nested_authority_time) + }); + let lifetime_deadline = pending.begin_observation.lifetime_deadline; + let plan = pending.proposal.plan(); + if provider_authority_time > lifetime_deadline + || provider_authority_time > plan.expires_at() + { + return Err(IdentityError::StaleEvidence); + } + + self.install_recovery_plan(plan)?; + self.pending_recovery = None; + self.lifecycle = ProjectionLifecycle::Active; + Ok(()) + } + + pub(crate) fn require_v1_recovery_crypto(&self) -> Result<(), IdentityError> { + match &self.crypto { + CryptoProjection::Stable(stable) + if stable.suite == CryptoSuiteDescriptor::v1()? + && stable.migrated_keys.is_empty() => + { + Ok(()) + } + CryptoProjection::Stable(_) + | CryptoProjection::Candidate { .. } + | CryptoProjection::Dual { .. } => Err(IdentityError::UnsupportedPolicyFeature { + feature: "recovery under a migrated cryptographic suite", + }), + } + } + + fn install_recovery_plan( + &mut self, + plan: &crate::RecoveryAuthorityPlan, + ) -> Result<(), IdentityError> { + for descriptor in plan.replacement_controllers() { + let identifier = descriptor.id()?; + if self.revoked_controller(identifier).is_some() { + return Err(IdentityError::RevokedController); + } + if self.active_controllers.iter().any(|controller| { + controller.id() != identifier + && controller.signing_key() == descriptor.signing_key() + }) { + return Err(IdentityError::DuplicateSigningKey); + } + if self + .revoked_controllers + .iter() + .any(|controller| controller.signing_key() == descriptor.signing_key()) + { + return Err(IdentityError::DuplicateSigningKey); + } + if self.devices.iter().any(|device| { + device_descriptor_reuses_controller_key( + device.descriptor(), + descriptor.signing_key(), + ) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "controller/device public-key role separation", + }); + } + } + + let replacement_ids = plan + .replacement_controllers() + .iter() + .map(ControllerDescriptor::id) + .collect::, IdentityError>>()?; + let removed = self + .active_controllers + .iter() + .filter(|controller| replacement_ids.binary_search(&controller.id()).is_err()) + .cloned() + .collect::>(); + for controller in removed { + let index = match self + .revoked_controllers + .binary_search_by_key(&controller.id(), ProjectedController::id) + { + Ok(_) => return Err(IdentityError::StorageCorruption), + Err(index) => index, + }; + self.revoked_controllers.insert(index, controller); + } + let retained_count = plan + .replacement_controllers() + .len() + .checked_add(self.revoked_controllers.len()) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "recovery controller tombstones", + })?; + if retained_count > MAX_CONTROLLERS { + return Err(IdentityError::limit( + "recovery controller tombstones", + retained_count, + MAX_CONTROLLERS, + )); + } + self.active_controllers = plan + .replacement_controllers() + .iter() + .map(|descriptor| { + Ok(ProjectedController { + id: descriptor.id()?, + descriptor: descriptor.clone(), + }) + }) + .collect::, IdentityError>>()?; + + for retained in plan.retained_devices() { + let index = self + .devices + .binary_search_by_key(retained, ProjectedDevice::id) + .map_err(|_| IdentityError::DeviceNotAuthorized)?; + if self.devices[index].lifecycle == ProjectedDeviceLifecycle::Revoked { + return Err(IdentityError::DeviceRevoked); + } + } + for device in &mut self.devices { + if device.lifecycle != ProjectedDeviceLifecycle::Revoked + && plan.retained_devices().binary_search(&device.id()).is_err() + { + device.lifecycle = ProjectedDeviceLifecycle::Revoked; + } + } + + self.control_policy = plan.replacement_control_policy().clone(); + self.control_policy_id = self.control_policy.id()?; + self.recovery_policy = plan.replacement_recovery_policy().clone(); + self.recovery_policy_id = self.recovery_policy.id()?; + Ok(()) + } + + fn begin_crypto_migration( + &mut self, + begin: &crate::BeginCryptoMigration, + event_id: EventId, + ) -> Result<(), IdentityError> { + let CryptoProjection::Stable(previous) = &self.crypto else { + return Err(IdentityError::InvalidRelationship { + resource: "nested cryptographic migration", + }); + }; + let previous = previous.clone(); + let migration = begin.migration(); + let migration_id = migration.crypto_migration_id()?; + if migration.account_id() != self.account_id { + return Err(IdentityError::AccountMismatch); + } + if migration.from_suite_id() != previous.suite.crypto_suite_id()? { + return Err(IdentityError::InvalidRelationship { + resource: "migration active suite", + }); + } + let candidate_suite_id = migration.to_suite().crypto_suite_id()?; + if previous + .retired_suites + .iter() + .any(|retired| retired.suite_id == candidate_suite_id) + { + return Err(IdentityError::InvalidRelationship { + resource: "retired cryptographic suite reuse", + }); + } + let retiring_keys = stable_suite_keys( + &previous, + &self.active_controllers, + &self.revoked_controllers, + )?; + let projected_tombstones = previous + .key_tombstones + .len() + .checked_add(retiring_keys.len()) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "cryptographic key tombstones", + })?; + if projected_tombstones > MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "cryptographic key tombstones", + projected_tombstones, + MAX_HISTORY_PAGE_EVENTS, + )); + } + if previous.retired_suites.len() >= MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "retired cryptographic suites", + previous.retired_suites.len().saturating_add(1), + MAX_HISTORY_PAGE_EVENTS, + )); + } + if migration.bindings().len() != self.active_controllers.len() + || begin.proofs().as_slice().len() != self.active_controllers.len() + { + return Err(IdentityError::InvalidRelationship { + resource: "complete controller migration binding set", + }); + } + let signed_message = migration_id.to_canonical_bytes()?; + for ((controller, binding), proof) in self + .active_controllers + .iter() + .zip(migration.bindings()) + .zip(begin.proofs().as_slice()) + { + if binding.controller_id() != controller.id() + || proof.controller_id() != controller.id() + || proof.migration_id() != migration_id + { + return Err(IdentityError::InvalidRelationship { + resource: "migration controller proof coverage", + }); + } + if migration_key_reuses_retained_material( + binding.new_signing_key(), + &previous, + &self.active_controllers, + &self.revoked_controllers, + &self.devices, + ) { + return Err(IdentityError::DuplicateSigningKey); + } + let current_keys = self.verification_keys(controller.id())?; + if current_keys.len() != 1 || binding.old_key_id() != current_keys[0].controller_key_id + { + return Err(IdentityError::InvalidRelationship { + resource: "migration old controller key", + }); + } + crate::verifier::verify_algorithm_signature( + current_keys[0].algorithm_code, + ¤t_keys[0].public_key, + proof.old_key_signature(), + &signed_message, + )?; + crate::verifier::verify_algorithm_signature( + binding.new_signing_key().algorithm_code(), + binding.new_signing_key().as_bytes(), + proof.new_key_signature(), + &signed_message, + )?; + } + self.crypto = CryptoProjection::Candidate { + previous, + migration: migration.clone(), + begin_event_id: event_id, + }; + self.lifecycle = ProjectionLifecycle::MigrationPending; + Ok(()) + } + + fn activate_crypto_migration( + &mut self, + activate: &crate::ActivateCryptoMigration, + event_id: EventId, + ) -> Result<(), IdentityError> { + let CryptoProjection::Candidate { + previous, + migration, + begin_event_id, + } = self.crypto.clone() + else { + return Err(IdentityError::InvalidRelationship { + resource: "activate without candidate migration", + }); + }; + if activate.migration_id() != migration.crypto_migration_id()? + || activate.begin_event_id() != begin_event_id + { + return Err(IdentityError::InvalidRelationship { + resource: "activate migration compare-and-set", + }); + } + self.crypto = CryptoProjection::Dual { + previous, + migration, + begin_event_id, + activation_event_id: event_id, + }; + self.lifecycle = ProjectionLifecycle::MigrationDual; + Ok(()) + } + + fn retire_crypto_suite( + &mut self, + retire: &crate::RetireCryptoSuite, + resulting_epoch: Epoch, + ) -> Result<(), IdentityError> { + match (self.crypto.clone(), retire.mode()) { + ( + CryptoProjection::Candidate { + previous, + migration, + begin_event_id, + }, + crate::RetireCryptoSuiteMode::AbortCandidate, + ) => { + if retire.migration_id() != migration.crypto_migration_id()? + || retire.phase_event_id() != begin_event_id + || retire.successor_account_id().is_some() + { + return Err(IdentityError::InvalidRelationship { + resource: "abort candidate migration compare-and-set", + }); + } + self.crypto = CryptoProjection::Stable(previous); + self.lifecycle = ProjectionLifecycle::Active; + Ok(()) + } + ( + CryptoProjection::Dual { + previous, + migration, + activation_event_id, + .. + }, + crate::RetireCryptoSuiteMode::RetirePrevious, + ) => { + if retire.migration_id() != migration.crypto_migration_id()? + || retire.phase_event_id() != activation_event_id + || retire.successor_account_id() != migration.successor_account_id() + { + return Err(IdentityError::InvalidRelationship { + resource: "retire previous suite compare-and-set", + }); + } + let migrated_keys = migration + .bindings() + .iter() + .map(|binding| MigrationKey { + controller_id: binding.controller_id(), + key: binding.new_signing_key().clone(), + }) + .collect(); + let mut retired_suites = previous.retired_suites.clone(); + retired_suites.push(RetiredCryptoSuite { + suite_id: previous.suite.crypto_suite_id()?, + retired_at: resulting_epoch, + }); + let mut key_tombstones = previous.key_tombstones.clone(); + key_tombstones.extend(stable_suite_keys( + &previous, + &self.active_controllers, + &self.revoked_controllers, + )?); + key_tombstones.sort_unstable_by(|left, right| { + left.algorithm_code() + .cmp(&right.algorithm_code()) + .then_with(|| left.as_bytes().cmp(right.as_bytes())) + }); + key_tombstones.dedup_by(|left, right| { + left.algorithm_code() == right.algorithm_code() + && left.as_bytes() == right.as_bytes() + }); + self.crypto = CryptoProjection::Stable(StableCrypto { + suite: migration.to_suite().clone(), + migrated_keys, + retired_suites, + key_tombstones, + }); + self.lifecycle = if retire.successor_account_id().is_some() { + self.retirement = Some(RetirementProjection::CryptoMigration { + migration_id: retire.migration_id(), + successor_account_id: retire + .successor_account_id() + .ok_or(IdentityError::StorageCorruption)?, + retired_at: resulting_epoch, + }); + ProjectionLifecycle::Retired + } else { + ProjectionLifecycle::Active + }; + Ok(()) + } + _ => Err(IdentityError::InvalidRelationship { + resource: "cryptographic suite retirement phase", + }), + } + } + + pub(crate) fn active_controller( + &self, + controller_id: ControllerId, + ) -> Option<&ProjectedController> { + self.active_controllers + .binary_search_by_key(&controller_id, ProjectedController::id) + .ok() + .map(|index| &self.active_controllers[index]) + } + + pub(crate) fn revoked_controller( + &self, + controller_id: ControllerId, + ) -> Option<&ProjectedController> { + self.revoked_controllers + .binary_search_by_key(&controller_id, ProjectedController::id) + .ok() + .map(|index| &self.revoked_controllers[index]) + } + + pub(crate) fn verification_keys( + &self, + controller_id: ControllerId, + ) -> Result, IdentityError> { + let controller = self + .active_controller(controller_id) + .ok_or(IdentityError::UnknownController)?; + match &self.crypto { + CryptoProjection::Stable(stable) + | CryptoProjection::Candidate { + previous: stable, .. + } => stable_verification_keys(stable, controller), + CryptoProjection::Dual { + previous, + migration, + .. + } => { + let mut keys = stable_verification_keys(previous, controller)?; + let binding = migration + .bindings() + .binary_search_by_key(&controller_id, |binding| binding.controller_id()) + .ok() + .map(|index| &migration.bindings()[index]) + .ok_or(IdentityError::InvalidRelationship { + resource: "dual migration controller binding", + })?; + keys.push(VerificationKey { + crypto_suite_id: migration.to_suite().crypto_suite_id()?, + controller_key_id: ControllerKeyId::for_algorithm_key( + binding.new_signing_key(), + )?, + algorithm_code: binding.new_signing_key().algorithm_code(), + public_key: binding.new_signing_key().as_bytes().to_vec(), + }); + keys.sort_unstable_by_key(|key| (key.crypto_suite_id, key.controller_key_id)); + Ok(keys) + } + } + } +} + +fn stable_verification_keys( + stable: &StableCrypto, + controller: &ProjectedController, +) -> Result, IdentityError> { + if stable.migrated_keys.is_empty() { + let signing_key = controller.signing_key(); + return Ok(vec![VerificationKey { + crypto_suite_id: stable.suite.crypto_suite_id()?, + controller_key_id: ControllerKeyId::for_signing_key(&signing_key)?, + algorithm_code: crate::SignatureAlgorithm::Ed25519.code(), + public_key: signing_key.as_bytes().to_vec(), + }]); + } + let migrated = stable + .migrated_keys + .binary_search_by_key(&controller.id(), |key| key.controller_id) + .ok() + .map(|index| &stable.migrated_keys[index]) + .ok_or(IdentityError::InvalidRelationship { + resource: "migrated controller verification key", + })?; + Ok(vec![VerificationKey { + crypto_suite_id: stable.suite.crypto_suite_id()?, + controller_key_id: ControllerKeyId::for_algorithm_key(&migrated.key)?, + algorithm_code: migrated.key.algorithm_code(), + public_key: migrated.key.as_bytes().to_vec(), + }]) +} + +fn stable_crypto_state_material( + stable: &StableCrypto, +) -> Result, IdentityError> { + Ok(StableCryptoStateMaterial { + current_suite_id: stable.suite.crypto_suite_id()?, + migrated_keys: &stable.migrated_keys, + retired_suites: &stable.retired_suites, + key_tombstones: &stable.key_tombstones, + }) +} + +fn stable_suite_keys( + stable: &StableCrypto, + active: &[ProjectedController], + revoked: &[ProjectedController], +) -> Result, IdentityError> { + if !stable.migrated_keys.is_empty() { + return Ok(stable + .migrated_keys + .iter() + .map(|retained| retained.key.clone()) + .collect()); + } + active + .iter() + .chain(revoked) + .map(|controller| { + AlgorithmPublicKey::new( + stable.suite.signature_algorithm_code(), + controller.signing_key().as_bytes().to_vec(), + ) + }) + .collect() +} + +fn migration_key_reuses_retained_material( + candidate: &AlgorithmPublicKey, + stable: &StableCrypto, + active: &[ProjectedController], + revoked: &[ProjectedController], + devices: &[ProjectedDevice], +) -> bool { + stable + .migrated_keys + .iter() + .any(|retained| retained.key.as_bytes() == candidate.as_bytes()) + || stable + .key_tombstones + .iter() + .any(|retired| retired.as_bytes() == candidate.as_bytes()) + || active + .iter() + .chain(revoked) + .any(|controller| controller.signing_key().as_bytes() == candidate.as_bytes()) + || devices.iter().any(|device| { + let descriptor = device.descriptor(); + descriptor.application_signing_key().as_bytes() == candidate.as_bytes() + || descriptor.agreement_key().as_bytes() == candidate.as_bytes() + || descriptor.endpoint_key().as_signing_key().as_bytes() == candidate.as_bytes() + }) +} + +fn device_descriptors_reuse_key( + left: &crate::DeviceDescriptor, + right: &crate::DeviceDescriptor, +) -> bool { + let left_application = left.application_signing_key(); + let left_agreement = left.agreement_key(); + let left_endpoint = left.endpoint_key().as_signing_key(); + let right_application = right.application_signing_key(); + let right_agreement = right.agreement_key(); + let right_endpoint = right.endpoint_key().as_signing_key(); + let left_keys = [ + left_application.as_bytes(), + left_agreement.as_bytes(), + left_endpoint.as_bytes(), + ]; + let right_keys = [ + right_application.as_bytes(), + right_agreement.as_bytes(), + right_endpoint.as_bytes(), + ]; + left_keys + .iter() + .any(|left_key| right_keys.iter().any(|right_key| left_key == right_key)) +} + +fn device_descriptor_reuses_controller_key( + descriptor: &crate::DeviceDescriptor, + controller_key: SigningPublicKey, +) -> bool { + descriptor.application_signing_key() == controller_key + || descriptor.agreement_key().as_bytes() == controller_key.as_bytes() + || descriptor.endpoint_key().as_signing_key() == controller_key +} + +fn operation_changes_epoch(operation: &AccountOperation) -> bool { + match AccountState::operation_kind_advances_epoch(operation.kind()) { + Some(changes_epoch) => changes_epoch, + None => matches!( + operation, + AccountOperation::RetireCryptoSuite(retire) + if retire.mode() == crate::RetireCryptoSuiteMode::RetirePrevious + ), + } +} + +fn expected_epoch( + state: &AccountState, + operation: &AccountOperation, +) -> Result { + if operation_changes_epoch(operation) { + state.epoch.checked_next() + } else { + Ok(state.epoch) + } +} + +fn transition_effects( + event_id: EventId, + epoch: Epoch, + changes_epoch: bool, +) -> Vec { + let mut effects = Vec::with_capacity(if changes_epoch { 3 } else { 2 }); + effects.push(ProjectionEffect::PublishAccountEvent { event_id }); + if changes_epoch { + effects.push(ProjectionEffect::RotateGroupKeys { event_id, epoch }); + } + effects.push(ProjectionEffect::NotifyAccountChanged { event_id }); + effects +} + +fn merge_event_evidence( + retained: &AuthorizedEvent, + incoming: &AuthorizedEvent, +) -> Result { + if retained.body() != incoming.body() + || retained.admission_evidence() != incoming.admission_evidence() + { + return Err(IdentityError::InvalidIdentifier { + resource: "admitted event identity", + }); + } + let approvals = retained.approvals().merge(incoming.approvals())?; + if &approvals == retained.approvals() { + return Ok(retained.clone()); + } + AuthorizedEvent::new( + retained.body().clone(), + retained.admission_evidence().clone(), + approvals, + ) +} + +fn project_transition( + pre_state: &AccountState, + event: &AuthorizedEvent, + event_id: EventId, +) -> Result<(LineageEntry, AccountState), IdentityError> { + let mut projected = pre_state.clone(); + projected.lineage.clear(); + projected.lineage_bytes = 0; + if projected.fork.is_some() + && matches!(event.body().operation(), AccountOperation::ResolveFork(_)) + { + projected.apply_fork_resolution(event, event_id)?; + } else { + projected.fork = None; + projected.apply_new_linear(event, event_id)?; + } + let transition = projected + .lineage + .last() + .ok_or(IdentityError::StorageCorruption)?; + let transition = LineageEntry { + pre_state: transition.pre_state.clone(), + authority_state: transition.authority_state.clone(), + expected_epoch: transition.expected_epoch, + event: transition.event.clone(), + }; + Ok((transition, projected.detached_snapshot())) +} + +fn projection_lifecycle_code(lifecycle: ProjectionLifecycle) -> u16 { + match lifecycle { + ProjectionLifecycle::Active => 1, + ProjectionLifecycle::RecoveryPending => 2, + ProjectionLifecycle::Forked => 3, + ProjectionLifecycle::MigrationPending => 4, + ProjectionLifecycle::MigrationDual => 5, + ProjectionLifecycle::UpgradePending => 6, + ProjectionLifecycle::Retired => 7, + } +} + +fn semantic_projection_bytes(state: &AccountState) -> Result { + crate::codec::encode_wire(&CanonicalProjectionView { + account_id: state.account_id, + genesis_anchor: state.genesis_anchor, + protocol_major: state.protocol_major, + sequence: state.sequence, + epoch: state.epoch, + heads: &state.heads, + active_controllers: &state.active_controllers, + revoked_controllers: &state.revoked_controllers, + devices: &state.devices, + control_policy: &state.control_policy, + control_policy_id: state.control_policy_id, + recovery_policy: &state.recovery_policy, + recovery_policy_id: state.recovery_policy_id, + provider_policy: &state.provider_policy, + provider_policy_id: state.provider_policy_id, + pending_recovery: &state.pending_recovery, + crypto: &state.crypto, + upgrade: &state.upgrade, + retirement: &state.retirement, + lifecycle_code: projection_lifecycle_code(state.lifecycle), + }) + .map(|bytes| bytes.len()) +} + +fn checked_evidence_add(total: &mut usize, amount: usize) -> Result<(), IdentityError> { + *total = total + .checked_add(amount) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "account projection evidence bytes", + })?; + Ok(()) +} + +fn lineage_entry_evidence_bytes(entry: &LineageEntry) -> Result { + let mut total = entry.event.to_canonical_bytes()?.len(); + checked_evidence_add( + &mut total, + account_state_evidence_bytes(&entry.pre_state, 0)?, + )?; + if let Some(authority) = &entry.authority_state { + checked_evidence_add(&mut total, account_state_evidence_bytes(authority, 0)?)?; + } + Ok(total) +} + +fn account_state_evidence_bytes( + state: &AccountState, + depth: usize, +) -> Result { + if depth > MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "nested account projection evidence", + depth, + MAX_HISTORY_PAGE_EVENTS, + )); + } + let mut total = semantic_projection_bytes(state)?; + if let Some(fork) = &state.fork { + checked_evidence_add( + &mut total, + account_state_evidence_bytes(&fork.common_state, depth.saturating_add(1))?, + )?; + checked_evidence_add(&mut total, fork.common_ancestor.to_canonical_bytes()?.len())?; + checked_evidence_add( + &mut total, + fork.conflict_predecessors.to_canonical_bytes()?.len(), + )?; + for branch in &fork.branches { + checked_evidence_add( + &mut total, + account_state_evidence_bytes(&branch.projected_state, depth.saturating_add(1))?, + )?; + for transition in &branch.transitions { + checked_evidence_add(&mut total, transition.event.to_canonical_bytes()?.len())?; + checked_evidence_add( + &mut total, + account_state_evidence_bytes(&transition.pre_state, depth.saturating_add(1))?, + )?; + if let Some(authority) = &transition.authority_state { + checked_evidence_add( + &mut total, + account_state_evidence_bytes(authority, depth.saturating_add(1))?, + )?; + } + } + } + } + Ok(total) +} + +fn sort_fork_branches(branches: &mut Vec) -> Result<(), IdentityError> { + let mut identified = Vec::with_capacity(branches.len()); + for branch in branches.drain(..) { + identified.push((fork_branch_event_id(&branch)?, branch)); + } + identified.sort_unstable_by_key(|(event_id, _)| *event_id); + branches.extend(identified.into_iter().map(|(_, branch)| branch)); + Ok(()) +} + +fn fork_head_ids(branches: &[ForkBranch]) -> Result, IdentityError> { + branches.iter().map(fork_branch_event_id).collect() +} + +fn fork_branch_event_id(branch: &ForkBranch) -> Result { + branch + .transitions + .last() + .ok_or(IdentityError::StorageCorruption)? + .event + .event_id() +} + +fn validate_fork_evidence_bound( + common_state: &AccountState, + branches: &[ForkBranch], +) -> Result<(), IdentityError> { + let mut total = account_state_evidence_bytes(common_state, 0)?; + for branch in branches { + checked_evidence_add( + &mut total, + account_state_evidence_bytes(&branch.projected_state, 0)?, + )?; + for transition in &branch.transitions { + checked_evidence_add(&mut total, lineage_entry_evidence_bytes(transition)?)?; + } + } + if total > MAX_FORK_EVIDENCE_BYTES { + return Err(IdentityError::limit( + "account fork evidence bytes", + total, + MAX_FORK_EVIDENCE_BYTES, + )); + } + Ok(()) +} diff --git a/protocols/krikos-identity/src/store.rs b/protocols/krikos-identity/src/store.rs new file mode 100644 index 00000000000..f74934bc60e --- /dev/null +++ b/protocols/krikos-identity/src/store.rs @@ -0,0 +1,1897 @@ +//! Atomic identity source-record persistence and durable effect contracts. + +#[cfg(feature = "fs-store")] +mod redb; + +use std::{ + collections::BTreeMap, + future::Future, + pin::Pin, + sync::{Arc, Mutex, MutexGuard}, +}; + +#[cfg(feature = "fs-store")] +pub use redb::RedbAccountStore; +use serde::Serialize; + +use crate::{ + AccountGenesis, AccountId, AccountRevision, AccountState, ApplicationId, ApplyDisposition, + ApplyOutcome, AuthorizedEvent, CanonicalWire, CheckpointId, Epoch, EventAuthorizationId, + EventId, GroupId, GroupKeyEpoch, GroupKeyRotation, IdentityError, ProjectionEffect, + ProjectionLifecycle, RecipientKeyWraps, Sequence, SignedCheckpoint, Timestamp, + VerifiedCheckpoint, + limits::{ + IDENTITY_QUEUE_CAPACITY, MAX_HISTORY_PAGE_BYTES, MAX_HISTORY_PAGE_EVENTS, MAX_RETRIES, + }, +}; + +pub(crate) const MAX_STORED_CHECKPOINTS: usize = 65_536; + +/// Owned future returned by store contracts without imposing an async runtime. +pub type StoreFuture<'a, T> = Pin> + Send + 'a>>; + +/// Canonical evidence that multiple valid bodies share an account pre-state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForkEvidenceRecord { + sequence: Sequence, + heads: Vec, +} + +/// Stable identifier of one deterministic projection effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EffectId([u8; 32]); + +impl EffectId { + /// Exact domain-separated effect digest bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + #[cfg(feature = "provider-store")] + pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +/// Caller-generated nonzero identifier for one exclusive effect lease. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LeaseId([u8; 16]); + +impl LeaseId { + /// Validate a nonzero, unpredictable lease identifier. + pub fn new(bytes: [u8; 16]) -> Result { + if bytes == [0; 16] { + return Err(IdentityError::ZeroValue { + resource: "effect lease identifier", + }); + } + Ok(Self(bytes)) + } + + /// Exact lease bytes. + pub const fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } +} + +/// Typed executor failure retained in the durable outbox. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectFailure { + /// A retryable executor or dependency failure. + Transient(u16), + /// A non-retryable effect failure retained for operator action. + Permanent(u16), +} + +impl EffectFailure { + /// Construct a retryable nonzero stable failure code. + pub fn transient(code: u16) -> Result { + if code == 0 { + return Err(IdentityError::ZeroValue { + resource: "effect failure code", + }); + } + Ok(Self::Transient(code)) + } + + /// Construct a terminal nonzero stable failure code. + pub fn permanent(code: u16) -> Result { + if code == 0 { + return Err(IdentityError::ZeroValue { + resource: "effect failure code", + }); + } + Ok(Self::Permanent(code)) + } +} + +/// Explicit bounded request to claim ready or lease-expired effects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaimEffects { + now: Timestamp, + leased_until: Timestamp, + lease_id: LeaseId, + limit: usize, +} + +impl ClaimEffects { + /// Validate a claim request without consulting wall-clock time. + pub fn new( + now: Timestamp, + leased_until: Timestamp, + lease_id: LeaseId, + limit: usize, + ) -> Result { + if leased_until <= now { + return Err(IdentityError::InvalidRelationship { + resource: "effect lease time range", + }); + } + if limit == 0 || limit > IDENTITY_QUEUE_CAPACITY { + return Err(IdentityError::limit( + "effect claim batch", + limit, + IDENTITY_QUEUE_CAPACITY, + )); + } + Ok(Self { + now, + leased_until, + lease_id, + limit, + }) + } + + /// Explicit claim-evaluation time. + pub const fn now(self) -> Timestamp { + self.now + } + + /// Exclusive lease expiry. + pub const fn leased_until(self) -> Timestamp { + self.leased_until + } + + /// Idempotency and ownership token for this claim. + pub const fn lease_id(self) -> LeaseId { + self.lease_id + } + + /// Maximum effects returned by this claim. + pub const fn limit(self) -> usize { + self.limit + } +} + +/// Durable outer lifecycle of one effect-outbox record. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectStatus { + /// Ready to be claimed at its explicit retry time. + Pending, + /// Exclusively leased to one effect executor. + Claimed, + /// Successfully completed and retained for audit/idempotency. + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingEffect { + Scheduled(Timestamp), + Exhausted(Timestamp), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EffectState { + Pending(PendingEffect), + Claimed { + lease_id: LeaseId, + leased_until: Timestamp, + }, + Completed { + lease_id: LeaseId, + completed_at: Timestamp, + }, +} + +/// Durable idempotently keyed projection effect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectRecord { + id: EffectId, + account_id: AccountId, + effect: ProjectionEffect, + state: EffectState, + attempt_count: u8, + last_failure: Option, +} + +/// Persisted public portion of one successfully committed group-key rotation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredGroupKeyRotation { + account_id: AccountId, + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: Epoch, + group_key_epoch: GroupKeyEpoch, + revision_heads: Vec, + recipient_key_wraps: RecipientKeyWraps, +} + +impl StoredGroupKeyRotation { + /// Account owning the group. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Application owning the group. + pub const fn application_id(&self) -> ApplicationId { + self.application_id + } + + /// Application-defined group. + pub const fn group_id(&self) -> GroupId { + self.group_id + } + + /// Account epoch used to select recipients. + pub const fn authorizing_account_epoch(&self) -> Epoch { + self.authorizing_account_epoch + } + + /// Persisted application group-key epoch. + pub const fn group_key_epoch(&self) -> GroupKeyEpoch { + self.group_key_epoch + } + + /// Exact account revision heads revalidated at commit time. + pub fn revision_heads(&self) -> &[EventId] { + &self.revision_heads + } + + /// Complete canonical recipient wraps. + pub const fn recipient_key_wraps(&self) -> &RecipientKeyWraps { + &self.recipient_key_wraps + } +} + +impl EffectRecord { + /// Stable body-derived effect identifier. + pub const fn id(&self) -> EffectId { + self.id + } + + /// Account whose deterministic transition requested this effect. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact deterministic work description. + pub const fn effect(&self) -> ProjectionEffect { + self.effect + } + + /// Current durable lifecycle. + pub const fn status(&self) -> EffectStatus { + match self.state { + EffectState::Pending(_) => EffectStatus::Pending, + EffectState::Claimed { .. } => EffectStatus::Claimed, + EffectState::Completed { .. } => EffectStatus::Completed, + } + } + + /// Number of exclusive executions attempted so far. + pub const fn attempt_count(&self) -> u8 { + self.attempt_count + } + + /// Most recent typed executor failure, if any. + pub const fn last_failure(&self) -> Option { + self.last_failure + } + + /// Whether the bounded retry budget has been exhausted. + pub const fn retry_exhausted(&self) -> bool { + matches!( + self.state, + EffectState::Pending(PendingEffect::Exhausted(_)) + ) + } + + /// Lease that owns the current claimed or completed execution, when applicable. + pub const fn execution_lease_id(&self) -> Option { + match self.state { + EffectState::Claimed { lease_id, .. } | EffectState::Completed { lease_id, .. } => { + Some(lease_id) + } + EffectState::Pending(_) => None, + } + } +} + +impl ForkEvidenceRecord { + /// Conflicting sequence represented by this record. + pub const fn sequence(&self) -> Sequence { + self.sequence + } + + /// Complete sorted conflicting head set. + pub fn heads(&self) -> &[EventId] { + &self.heads + } +} + +/// Authenticated account source records plus their reconstructed current projection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountSnapshot { + genesis: AccountGenesis, + state: AccountState, + revision: AccountRevision, + events: Vec, + checkpoints: Vec, + fork_evidence: Vec, + outbox: Vec, + group_key_rotations: Vec, + checkpoint_count: u64, +} + +/// One bounded checkpoint-journal record in durable insertion order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CheckpointJournalRecord { + cursor: u64, + checkpoint_id: CheckpointId, + checkpoint: SignedCheckpoint, + transition_event: Option, +} + +impl CheckpointJournalRecord { + /// Stable insertion cursor for bounded history continuation. + pub const fn cursor(&self) -> u64 { + self.cursor + } + + /// Stable body-only checkpoint identifier. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Canonical signed checkpoint envelope. + pub const fn checkpoint(&self) -> &SignedCheckpoint { + &self.checkpoint + } + + /// Retained destructive transition required by transition-derived authorization. + pub const fn transition_event(&self) -> Option<&AuthorizedEvent> { + self.transition_event.as_ref() + } +} + +/// Bounded page of authenticated durable checkpoints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckpointJournalPage { + records: Vec, + next_cursor: Option, +} + +impl CheckpointJournalPage { + /// Authenticated checkpoint records in durable insertion order. + pub fn records(&self) -> &[CheckpointJournalRecord] { + &self.records + } + + /// Exclusive journal cursor for the next bounded request. + pub const fn next_cursor(&self) -> Option { + self.next_cursor + } +} + +/// One canonical account event at its stable position in a frozen source revision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EventHistoryRecord { + cursor: u64, + event: AuthorizedEvent, +} + +impl EventHistoryRecord { + /// Zero-based position in the deterministic history of the frozen revision. + pub const fn cursor(&self) -> u64 { + self.cursor + } + + /// Canonical event envelope, with all durably retained compatible approvals merged. + pub const fn event(&self) -> &AuthorizedEvent { + &self.event + } +} + +/// Bounded page of authenticated events from one exact complete source-head set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventHistoryPage { + source_revision: AccountRevision, + records: Vec, + next_cursor: Option, +} + +impl EventHistoryPage { + /// Exact account and complete sorted heads that freeze this history view. + pub const fn source_revision(&self) -> &AccountRevision { + &self.source_revision + } + + /// Deterministically ordered authenticated event records. + pub fn records(&self) -> &[EventHistoryRecord] { + &self.records + } + + /// Opaque continuation bound to this exact frozen revision and deterministic ordering. + pub const fn next_cursor(&self) -> Option<&EventHistoryCursor> { + self.next_cursor.as_ref() + } +} + +/// Opaque event-history continuation bound to one exact frozen source revision. +/// +/// Public callers can retain and replay a cursor returned by [`EventHistoryPage`], but cannot +/// construct or alter its position. Network sync may reconstruct it only after verifying the +/// keyed [`crate::SyncCursor`] that authenticates the same revision and position. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventHistoryCursor { + source_revision: AccountRevision, + position: u64, +} + +impl EventHistoryCursor { + pub(crate) const fn from_verified_sync( + source_revision: AccountRevision, + position: u64, + ) -> Self { + Self { + source_revision, + position, + } + } + + /// Exact account and complete sorted heads to which this cursor is bound. + pub const fn source_revision(&self) -> &AccountRevision { + &self.source_revision + } + + /// Stable zero-based position last delivered from the frozen history. + pub const fn position(&self) -> u64 { + self.position + } +} + +/// Result of one revision-bound idempotent checkpoint commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckpointCommitReceipt { + checkpoint_id: CheckpointId, + snapshot: AccountSnapshot, +} + +impl CheckpointCommitReceipt { + /// Stable body-only checkpoint identifier committed by this transaction. + pub const fn checkpoint_id(&self) -> CheckpointId { + self.checkpoint_id + } + + /// Complete post-transaction account snapshot with a bounded recent checkpoint view. + pub const fn snapshot(&self) -> &AccountSnapshot { + &self.snapshot + } +} + +impl AccountSnapshot { + /// Canonical account genesis source record. + pub const fn genesis(&self) -> &AccountGenesis { + &self.genesis + } + + /// Projection reconstructed from authenticated source records. + pub const fn state(&self) -> &AccountState { + &self.state + } + + /// Exact complete revision token of the reconstructed projection. + pub fn revision(&self) -> &AccountRevision { + &self.revision + } + + /// Canonical retained event envelopes in deterministic replay order. + pub fn events(&self) -> &[AuthorizedEvent] { + &self.events + } + + /// Most recent bounded canonical signed checkpoints. + pub fn checkpoints(&self) -> &[SignedCheckpoint] { + &self.checkpoints + } + + /// Total number of durable checkpoints available through bounded journal pagination. + pub const fn checkpoint_count(&self) -> u64 { + self.checkpoint_count + } + + /// Derived bounded evidence for every unresolved fork. + pub fn fork_evidence(&self) -> &[ForkEvidenceRecord] { + &self.fork_evidence + } + + /// Complete stable effect outbox, including completed audit records. + pub fn outbox(&self) -> &[EffectRecord] { + &self.outbox + } + + /// Latest committed rotation for every protected application group. + pub fn group_key_rotations(&self) -> &[StoredGroupKeyRotation] { + &self.group_key_rotations + } +} + +/// Result of one atomic event/source/effect commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitReceipt { + outcome: ApplyOutcome, + snapshot: AccountSnapshot, +} + +/// Result of one atomic bounded multi-event reconciliation commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BatchCommitReceipt { + outcomes: Vec, + snapshot: AccountSnapshot, +} + +impl BatchCommitReceipt { + /// One pure projection result for every supplied envelope after deterministic ordering. + pub fn outcomes(&self) -> &[ApplyOutcome] { + &self.outcomes + } + + /// Complete post-transaction account snapshot. + pub const fn snapshot(&self) -> &AccountSnapshot { + &self.snapshot + } +} + +impl CommitReceipt { + /// Pure projection disposition and generated effects. + pub const fn outcome(&self) -> &ApplyOutcome { + &self.outcome + } + + /// Complete post-transaction account snapshot. + pub const fn snapshot(&self) -> &AccountSnapshot { + &self.snapshot + } +} + +#[derive(Debug, Clone)] +struct StoredAccount { + genesis: AccountGenesis, + events: BTreeMap, + event_journal: Vec, + outbox: BTreeMap, + group_key_rotations: BTreeMap<(ApplicationId, GroupId), StoredGroupKeyRotation>, + checkpoints: BTreeMap, + checkpoint_journal: Vec, + projection: AccountState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct StoredCheckpoint { + pub(crate) checkpoint: SignedCheckpoint, + pub(crate) transition_event: Option, +} + +impl StoredAccount { + fn snapshot(&self) -> Result { + let state = self.projection.clone(); + if state.account_id() != self.genesis.account_id()? { + return Err(IdentityError::StorageCorruption); + } + let ordered_events = self.events_in_journal_order()?; + let revision = state.revision_token(); + let fork_evidence = if state.lifecycle() == ProjectionLifecycle::Forked { + vec![ForkEvidenceRecord { + sequence: state.sequence(), + heads: state.heads().to_vec(), + }] + } else { + Vec::new() + }; + if self.checkpoints.len() != self.checkpoint_journal.len() + || self.checkpoints.len() > MAX_STORED_CHECKPOINTS + { + return Err(IdentityError::StorageCorruption); + } + let recent_start = self + .checkpoint_journal + .len() + .saturating_sub(MAX_HISTORY_PAGE_EVENTS); + let checkpoints = self.checkpoint_journal[recent_start..] + .iter() + .map(|checkpoint_id| { + self.checkpoints + .get(checkpoint_id) + .map(|record| record.checkpoint.clone()) + .ok_or(IdentityError::StorageCorruption) + }) + .collect::, IdentityError>>()?; + let checkpoint_count = u64::try_from(self.checkpoint_journal.len()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "checkpoint journal count", + } + })?; + Ok(AccountSnapshot { + genesis: self.genesis.clone(), + state, + revision, + events: ordered_events, + checkpoints, + fork_evidence, + outbox: self.outbox.values().cloned().collect(), + group_key_rotations: self.group_key_rotations.values().cloned().collect(), + checkpoint_count, + }) + } + + #[cfg(feature = "fs-store")] + fn rebuild_projection_and_effects( + &self, + ) -> Result<(AccountState, BTreeMap), IdentityError> { + let mut state = AccountState::from_genesis(&self.genesis)?; + let account_id = state.account_id(); + let mut required_effects = BTreeMap::new(); + let ordered_events = self.events_in_journal_order()?; + for event in &ordered_events { + let outcome = match state.validate_and_apply(event) { + Ok(outcome) => outcome, + Err(IdentityError::HistoricalStateRequired { .. }) => self + .apply_authenticated_historical_conflict(&mut state, event) + .map_err(|_| IdentityError::StorageCorruption)?, + Err(_) => return Err(IdentityError::StorageCorruption), + }; + for effect in outcome.effects() { + let id = derive_effect_id(account_id, *effect)?; + if required_effects.insert(id, *effect).is_some() { + return Err(IdentityError::StorageCorruption); + } + } + } + Ok((state, required_effects)) + } + + fn events_in_journal_order(&self) -> Result, IdentityError> { + let mut seen = std::collections::BTreeSet::new(); + let mut ordered = Vec::with_capacity(self.events.len()); + for event_id in &self.event_journal { + if !seen.insert(*event_id) { + return Err(IdentityError::StorageCorruption); + } + let mut envelopes = self + .events + .values() + .filter_map(|event| match event.event_id() { + Ok(candidate) if candidate == *event_id => Some( + event + .event_authorization_id() + .map(|authorization_id| (authorization_id, event.clone())), + ), + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::, IdentityError>>()?; + if envelopes.is_empty() { + return Err(IdentityError::StorageCorruption); + } + envelopes.sort_unstable_by_key(|(authorization_id, _)| *authorization_id); + ordered.extend(envelopes.into_iter().map(|(_, event)| event)); + } + if ordered.len() != self.events.len() { + return Err(IdentityError::StorageCorruption); + } + Ok(ordered) + } + + fn insert_event(&mut self, event: AuthorizedEvent) -> Result<(), IdentityError> { + let event_id = event.event_id()?; + let authorization_id = event.event_authorization_id()?; + if self.events.contains_key(&authorization_id) { + return Ok(()); + } + if !self + .events + .values() + .any(|retained| retained.event_id() == Ok(event_id)) + { + self.event_journal.push(event_id); + } + self.events.insert(authorization_id, event); + Ok(()) + } + + fn apply_authenticated_historical_conflict( + &self, + state: &mut AccountState, + incoming: &AuthorizedEvent, + ) -> Result { + let current_ancestors = self.ancestor_closure(state.heads())?; + let incoming_id = incoming.event_id()?; + let mut accepted = self + .events + .values() + .filter_map(|candidate| { + let candidate_id = candidate.event_id().ok()?; + (candidate_id != incoming_id + && current_ancestors.contains(&candidate_id) + && candidate.body().sequence() == incoming.body().sequence() + && candidate.body().predecessors() == incoming.body().predecessors()) + .then_some((candidate_id, candidate)) + }) + .collect::>(); + accepted.sort_unstable_by_key(|(event_id, _)| *event_id); + let (accepted_id, _) = accepted + .first() + .copied() + .ok_or(IdentityError::StorageCorruption)?; + let accepted_path = self.authenticated_linear_path(state.heads(), accepted_id)?; + let historical_pre_state = self.reconstruct_pre_state(incoming)?; + state.validate_and_apply_historical_conflict( + &historical_pre_state, + &accepted_path, + incoming, + ) + } + + fn authenticated_linear_path( + &self, + current_heads: &[EventId], + accepted_id: EventId, + ) -> Result, IdentityError> { + let [current_head] = current_heads else { + return Err(IdentityError::StorageCorruption); + }; + let mut cursor = *current_head; + let mut reversed = Vec::new(); + loop { + if reversed.len() >= self.event_journal.len() { + return Err(IdentityError::StorageCorruption); + } + let event = self.canonical_envelope(cursor)?; + reversed.push(event.clone()); + if cursor == accepted_id { + break; + } + let [predecessor] = event + .body() + .predecessors() + .event_heads() + .ok_or(IdentityError::StorageCorruption)? + else { + return Err(IdentityError::StorageCorruption); + }; + cursor = *predecessor; + } + reversed.reverse(); + Ok(reversed) + } + + fn reconstruct_pre_state( + &self, + incoming: &AuthorizedEvent, + ) -> Result { + if let Some(anchor) = incoming.body().predecessors().genesis_anchor() { + if anchor != self.genesis.genesis_anchor()? { + return Err(IdentityError::StorageCorruption); + } + return AccountState::from_genesis(&self.genesis); + } + let heads = incoming + .body() + .predecessors() + .event_heads() + .ok_or(IdentityError::StorageCorruption)?; + let closure = self.ancestor_closure(heads)?; + let mut bodies = closure + .iter() + .map(|event_id| { + let event = self.canonical_envelope(*event_id)?; + Ok((event.body().sequence(), *event_id, event)) + }) + .collect::, IdentityError>>()?; + bodies.sort_unstable_by_key(|(sequence, event_id, _)| (*sequence, *event_id)); + let mut state = AccountState::from_genesis(&self.genesis)?; + for (_, _, event) in bodies { + state + .validate_and_apply(event) + .map_err(|_| IdentityError::StorageCorruption)?; + } + if state.heads() != heads { + return Err(IdentityError::StorageCorruption); + } + Ok(state) + } + + fn ancestor_closure( + &self, + heads: &[EventId], + ) -> Result, IdentityError> { + let mut closure = std::collections::BTreeSet::new(); + let mut pending = heads.to_vec(); + while let Some(event_id) = pending.pop() { + if !closure.insert(event_id) { + continue; + } + if closure.len() > self.event_journal.len() { + return Err(IdentityError::StorageCorruption); + } + let event = self.canonical_envelope(event_id)?; + if let Some(predecessors) = event.body().predecessors().event_heads() { + pending.extend_from_slice(predecessors); + } else if event.body().predecessors().genesis_anchor() + != Some(self.genesis.genesis_anchor()?) + { + return Err(IdentityError::StorageCorruption); + } + } + Ok(closure) + } + + fn canonical_envelope(&self, event_id: EventId) -> Result<&AuthorizedEvent, IdentityError> { + self.events + .iter() + .filter_map(|(authorization_id, event)| { + (event.event_id() == Ok(event_id)).then_some((*authorization_id, event)) + }) + .min_by_key(|(authorization_id, _)| *authorization_id) + .map(|(_, event)| event) + .ok_or(IdentityError::StorageCorruption) + } + + #[cfg(feature = "fs-store")] + fn state_at_event_head(&self, event_head: EventId) -> Result { + let closure = self.ancestor_closure(&[event_head])?; + let mut events = closure + .iter() + .map(|event_id| { + let event = self.canonical_envelope(*event_id)?; + Ok((event.body().sequence(), *event_id, event)) + }) + .collect::, IdentityError>>()?; + events.sort_unstable_by_key(|(sequence, event_id, _)| (*sequence, *event_id)); + let mut state = AccountState::from_genesis(&self.genesis)?; + for (_, _, event) in events { + state + .validate_and_apply(event) + .map_err(|_| IdentityError::StorageCorruption)?; + } + if state.heads() != [event_head] { + return Err(IdentityError::StorageCorruption); + } + Ok(state) + } + + #[cfg(feature = "fs-store")] + fn validate_checkpoint_journal(&self) -> Result<(), IdentityError> { + if self.checkpoints.len() != self.checkpoint_journal.len() + || self.checkpoints.len() > MAX_STORED_CHECKPOINTS + { + return Err(IdentityError::StorageCorruption); + } + let mut seen = std::collections::BTreeSet::new(); + for checkpoint_id in &self.checkpoint_journal { + if !seen.insert(*checkpoint_id) { + return Err(IdentityError::StorageCorruption); + } + let retained = self + .checkpoints + .get(checkpoint_id) + .ok_or(IdentityError::StorageCorruption)?; + if retained.checkpoint.checkpoint_id()? != *checkpoint_id { + return Err(IdentityError::StorageCorruption); + } + let state = self.state_at_event_head(retained.checkpoint.body().event_head())?; + let verified = crate::verify_checkpoint( + &state, + &retained.checkpoint, + retained.transition_event.as_ref(), + ) + .map_err(|_| IdentityError::StorageCorruption)?; + if verified.checkpoint() != &retained.checkpoint + || verified.transition_event() != retained.transition_event.as_ref() + { + return Err(IdentityError::StorageCorruption); + } + } + Ok(()) + } + + fn commit_checkpoint( + &mut self, + expected_revision: &AccountRevision, + checkpoint: VerifiedCheckpoint, + ) -> Result { + let current = self.projection.revision_token(); + if ¤t != expected_revision { + return Err(IdentityError::StaleRevision); + } + if checkpoint.checkpoint().body().account_id() != self.projection.account_id() { + return Err(IdentityError::AccountMismatch); + } + let reverified = crate::verify_checkpoint( + &self.projection, + checkpoint.checkpoint(), + checkpoint.transition_event(), + )?; + if reverified != checkpoint { + return Err(IdentityError::InvalidProof); + } + let checkpoint_id = checkpoint.checkpoint_id(); + let retained = StoredCheckpoint { + checkpoint: checkpoint.checkpoint().clone(), + transition_event: checkpoint.transition_event().cloned(), + }; + if let Some(existing) = self.checkpoints.get_mut(&checkpoint_id) { + if existing.transition_event != retained.transition_event { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint transition witness", + }); + } + let merged = existing.checkpoint.merge(&retained.checkpoint)?; + let reverified = crate::verify_checkpoint( + &self.projection, + &merged, + existing.transition_event.as_ref(), + )?; + if reverified.checkpoint() != &merged + || reverified.transition_event() != existing.transition_event.as_ref() + { + return Err(IdentityError::InvalidProof); + } + existing.checkpoint = merged; + return Ok(CheckpointCommitReceipt { + checkpoint_id, + snapshot: self.snapshot()?, + }); + } + if self.checkpoints.len() == MAX_STORED_CHECKPOINTS { + return Err(IdentityError::limit( + "stored checkpoint journal", + self.checkpoints.len().saturating_add(1), + MAX_STORED_CHECKPOINTS, + )); + } + self.checkpoints.insert(checkpoint_id, retained); + self.checkpoint_journal.push(checkpoint_id); + Ok(CheckpointCommitReceipt { + checkpoint_id, + snapshot: self.snapshot()?, + }) + } + + fn checkpoint_history( + &self, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> Result { + if maximum_records == 0 || maximum_records > MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "checkpoint history records", + maximum_records, + MAX_HISTORY_PAGE_EVENTS, + )); + } + if maximum_bytes == 0 || maximum_bytes > MAX_HISTORY_PAGE_BYTES { + return Err(IdentityError::limit( + "checkpoint history bytes", + maximum_bytes, + MAX_HISTORY_PAGE_BYTES, + )); + } + let start = match after_cursor { + None => 0, + Some(cursor) => usize::try_from(cursor) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "checkpoint history cursor", + })? + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "checkpoint history cursor", + })?, + }; + if start > self.checkpoint_journal.len() { + return Err(IdentityError::InvalidRelationship { + resource: "checkpoint history cursor", + }); + } + let mut records = Vec::new(); + let mut next_cursor = None; + for (index, checkpoint_id) in self.checkpoint_journal.iter().enumerate().skip(start) { + if records.len() == maximum_records { + next_cursor = records.last().map(CheckpointJournalRecord::cursor); + break; + } + let retained = self + .checkpoints + .get(checkpoint_id) + .ok_or(IdentityError::StorageCorruption)?; + let cursor = u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "checkpoint history cursor", + })?; + records.push(CheckpointJournalRecord { + cursor, + checkpoint_id: *checkpoint_id, + checkpoint: retained.checkpoint.clone(), + transition_event: retained.transition_event.clone(), + }); + let encoded = crate::codec::encode_wire(&(records.as_slice(), Some(cursor)))?; + if encoded.len() > maximum_bytes { + records.pop(); + if records.is_empty() { + return Err(IdentityError::limit( + "checkpoint history bytes", + encoded.len(), + maximum_bytes, + )); + } + next_cursor = records.last().map(CheckpointJournalRecord::cursor); + break; + } + } + Ok(CheckpointJournalPage { + records, + next_cursor, + }) + } + + fn event_history( + &self, + source_revision: &AccountRevision, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> Result { + if source_revision.account_id() != self.genesis.account_id()? { + return Err(IdentityError::AccountMismatch); + } + if maximum_records == 0 || maximum_records > MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "account event-history records", + maximum_records, + MAX_HISTORY_PAGE_EVENTS, + )); + } + if maximum_bytes == 0 || maximum_bytes > MAX_HISTORY_PAGE_BYTES { + return Err(IdentityError::limit( + "account event-history bytes", + maximum_bytes, + MAX_HISTORY_PAGE_BYTES, + )); + } + + for head in source_revision.heads() { + if !self + .events + .values() + .any(|event| event.event_id() == Ok(*head)) + { + return Err(IdentityError::InvalidRelationship { + resource: "account event-history source revision", + }); + } + } + let closure = self.ancestor_closure(source_revision.heads())?; + let mut by_event_id = BTreeMap::::new(); + for event in self.events.values() { + let event_id = event.event_id()?; + if !closure.contains(&event_id) { + continue; + } + match by_event_id.entry(event_id) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(event.clone()); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + let retained = slot.get(); + if retained.body() != event.body() + || retained.admission_evidence() != event.admission_evidence() + { + return Err(IdentityError::StorageCorruption); + } + let approvals = retained.approvals().merge(event.approvals())?; + let merged = AuthorizedEvent::new( + retained.body().clone(), + retained.admission_evidence().clone(), + approvals, + )?; + slot.insert(merged); + } + } + } + if by_event_id.len() != closure.len() { + return Err(IdentityError::StorageCorruption); + } + let mut events = by_event_id.into_iter().collect::>(); + events.sort_unstable_by_key(|(event_id, event)| (event.body().sequence(), *event_id)); + + let start = match after_cursor { + None => 0, + Some(cursor) => { + if cursor.source_revision != *source_revision { + return Err(IdentityError::InvalidRelationship { + resource: "account event-history cursor revision", + }); + } + usize::try_from(cursor.position) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "account event-history cursor", + })? + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "account event-history cursor", + })? + } + }; + if start > events.len() { + return Err(IdentityError::InvalidRelationship { + resource: "account event-history cursor", + }); + } + + let mut records = Vec::::new(); + let mut next_cursor = None::; + for (index, (_, event)) in events.into_iter().enumerate().skip(start) { + if records.len() == maximum_records { + next_cursor = records.last().map(|record| { + EventHistoryCursor::from_verified_sync(source_revision.clone(), record.cursor()) + }); + break; + } + let cursor = u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "account event-history cursor", + })?; + records.push(EventHistoryRecord { cursor, event }); + let encoded = crate::codec::encode_wire(&( + source_revision.account_id(), + source_revision.heads(), + records.as_slice(), + Some(cursor), + ))?; + if encoded.len() > maximum_bytes { + records.pop(); + if records.is_empty() { + return Err(IdentityError::limit( + "account event-history bytes", + encoded.len(), + maximum_bytes, + )); + } + next_cursor = records.last().map(|record| { + EventHistoryCursor::from_verified_sync(source_revision.clone(), record.cursor()) + }); + break; + } + } + Ok(EventHistoryPage { + source_revision: source_revision.clone(), + records, + next_cursor, + }) + } + + fn commit_event( + &mut self, + expected_revision: &AccountRevision, + event: AuthorizedEvent, + ) -> Result { + let account_id = expected_revision.account_id(); + if event.body().account_id() != account_id { + return Err(IdentityError::AccountMismatch); + } + let current_snapshot = self.snapshot()?; + let revision_matches = current_snapshot.revision() == expected_revision; + let mut staged_state = current_snapshot.state().clone(); + let outcome = match staged_state.validate_and_apply(&event) { + Ok(outcome) => outcome, + Err(IdentityError::HistoricalStateRequired { .. }) => { + self.apply_authenticated_historical_conflict(&mut staged_state, &event)? + } + Err(error) => return Err(error), + }; + if !revision_matches + && !matches!( + outcome.disposition(), + ApplyDisposition::Replay + | ApplyDisposition::ApprovalsMerged + | ApplyDisposition::ForkDetected + ) + { + return Err(IdentityError::StaleRevision); + } + self.insert_event(event)?; + insert_effects(&mut self.outbox, account_id, outcome.effects())?; + self.projection = staged_state.clone(); + let snapshot = self.snapshot()?; + if snapshot.revision() != &staged_state.revision_token() { + return Err(IdentityError::StorageCorruption); + } + Ok(CommitReceipt { outcome, snapshot }) + } + + fn commit_events( + &mut self, + expected_revision: &AccountRevision, + events: Vec, + ) -> Result { + if events.len() > crate::limits::MAX_EVENTS_PER_SYNC_BATCH { + return Err(IdentityError::limit( + "atomic event batch", + events.len(), + crate::limits::MAX_EVENTS_PER_SYNC_BATCH, + )); + } + let account_id = expected_revision.account_id(); + let ordered_events = canonical_event_order(events)?; + let current_snapshot = self.snapshot()?; + if current_snapshot.revision() != expected_revision { + return Err(IdentityError::StaleRevision); + } + let mut staged_state = current_snapshot.state().clone(); + let mut outcomes = Vec::with_capacity(ordered_events.len()); + for event in ordered_events { + if event.body().account_id() != account_id { + return Err(IdentityError::AccountMismatch); + } + let outcome = match staged_state.validate_and_apply(&event) { + Ok(outcome) => outcome, + Err(IdentityError::HistoricalStateRequired { .. }) => { + self.apply_authenticated_historical_conflict(&mut staged_state, &event)? + } + Err(error) => return Err(error), + }; + self.insert_event(event)?; + insert_effects(&mut self.outbox, account_id, outcome.effects())?; + outcomes.push(outcome); + } + self.projection = staged_state.clone(); + let snapshot = self.snapshot()?; + if snapshot.revision() != &staged_state.revision_token() { + return Err(IdentityError::StorageCorruption); + } + Ok(BatchCommitReceipt { outcomes, snapshot }) + } + + fn claim_effects(&mut self, request: ClaimEffects) -> Result, IdentityError> { + let already_claimed = self + .outbox + .values() + .filter(|record| { + matches!( + record.state, + EffectState::Claimed { lease_id, .. } if lease_id == request.lease_id + ) + }) + .take(request.limit) + .cloned() + .collect::>(); + if !already_claimed.is_empty() { + return Ok(already_claimed); + } + + let mut claimed = Vec::with_capacity(request.limit); + for record in self.outbox.values_mut() { + if claimed.len() == request.limit { + break; + } + let eligible = match record.state { + EffectState::Pending(PendingEffect::Scheduled(retry_at)) => retry_at <= request.now, + EffectState::Claimed { leased_until, .. } => leased_until <= request.now, + EffectState::Pending(PendingEffect::Exhausted(_)) + | EffectState::Completed { .. } => false, + }; + if !eligible { + continue; + } + if record.attempt_count >= MAX_RETRIES { + record.state = EffectState::Pending(PendingEffect::Exhausted(request.now)); + continue; + } + record.attempt_count = + record + .attempt_count + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "effect attempt count", + })?; + record.state = EffectState::Claimed { + lease_id: request.lease_id, + leased_until: request.leased_until, + }; + claimed.push(record.clone()); + } + Ok(claimed) + } + + fn complete_effect( + &mut self, + effect_id: EffectId, + lease_id: LeaseId, + completed_at: Timestamp, + ) -> Result<(), IdentityError> { + let record = self + .outbox + .get_mut(&effect_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "unknown effect record", + })?; + match record.state { + EffectState::Claimed { + lease_id: owner, .. + } if owner == lease_id => { + record.state = EffectState::Completed { + lease_id, + completed_at, + }; + Ok(()) + } + EffectState::Completed { + lease_id: owner, .. + } if owner == lease_id => Ok(()), + EffectState::Pending(_) + | EffectState::Claimed { .. } + | EffectState::Completed { .. } => Err(IdentityError::InvalidRelationship { + resource: "effect completion lease ownership", + }), + } + } + + fn retry_effect( + &mut self, + effect_id: EffectId, + lease_id: LeaseId, + retry_at: Timestamp, + failure: EffectFailure, + ) -> Result { + let record = self + .outbox + .get_mut(&effect_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "unknown effect record", + })?; + let EffectState::Claimed { + lease_id: owner, .. + } = record.state + else { + return Err(IdentityError::InvalidRelationship { + resource: "effect retry lifecycle", + }); + }; + if owner != lease_id { + return Err(IdentityError::InvalidRelationship { + resource: "effect retry lease ownership", + }); + } + record.last_failure = Some(failure); + let exhausted = + record.attempt_count >= MAX_RETRIES || matches!(failure, EffectFailure::Permanent(_)); + record.state = if exhausted { + EffectState::Pending(PendingEffect::Exhausted(retry_at)) + } else { + EffectState::Pending(PendingEffect::Scheduled(retry_at)) + }; + Ok(exhausted) + } + + fn commit_group_key_rotation( + &mut self, + effect_id: EffectId, + lease_id: LeaseId, + rotation: GroupKeyRotation, + completed_at: Timestamp, + ) -> Result { + rotation.validate_current_revision(&self.projection)?; + let effect = self + .outbox + .get(&effect_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "group rotation effect", + })?; + let ProjectionEffect::RotateGroupKeys { epoch, .. } = effect.effect else { + return Err(IdentityError::InvalidRelationship { + resource: "group rotation effect kind", + }); + }; + if epoch != rotation.authorizing_account_epoch() { + return Err(IdentityError::InvalidEpoch); + } + match effect.state { + EffectState::Claimed { + lease_id: owner, .. + } + | EffectState::Completed { + lease_id: owner, .. + } if owner == lease_id => {} + _ => { + return Err(IdentityError::InvalidRelationship { + resource: "group rotation effect lease", + }); + } + } + let record = StoredGroupKeyRotation { + account_id: rotation.account_id(), + application_id: rotation.application_id(), + group_id: rotation.group_id(), + authorizing_account_epoch: rotation.authorizing_account_epoch(), + group_key_epoch: rotation.group_key_epoch(), + revision_heads: rotation.account_revision().heads().to_vec(), + recipient_key_wraps: rotation.recipient_key_wraps().clone(), + }; + let key = (record.application_id, record.group_id); + if let Some(previous) = self.group_key_rotations.get(&key) { + if previous == &record { + let previous = previous.clone(); + self.complete_effect(effect_id, lease_id, completed_at)?; + return Ok(previous); + } + if previous.group_key_epoch >= record.group_key_epoch { + return Err(IdentityError::StaleRevision); + } + } + self.group_key_rotations.insert(key, record.clone()); + self.complete_effect(effect_id, lease_id, completed_at)?; + Ok(record) + } + + fn authorize_protected_write( + &self, + expected_revision: &AccountRevision, + application_id: ApplicationId, + group_id: GroupId, + ) -> Result<(), IdentityError> { + if &self.projection.revision_token() != expected_revision { + return Err(IdentityError::StaleRevision); + } + let current_epoch = self.projection.epoch(); + let mut rotation_required = false; + for effect in self.outbox.values() { + if let ProjectionEffect::RotateGroupKeys { epoch, .. } = effect.effect + && epoch == current_epoch + { + rotation_required = true; + if effect.status() != EffectStatus::Completed { + return Err(IdentityError::ProtectedWritesBlocked); + } + } + } + if !rotation_required { + return Ok(()); + } + let rotation = self + .group_key_rotations + .get(&(application_id, group_id)) + .ok_or(IdentityError::ProtectedWritesBlocked)?; + if rotation.authorizing_account_epoch != current_epoch { + return Err(IdentityError::ProtectedWritesBlocked); + } + Ok(()) + } +} + +/// Async-capable atomic account source-record store. +pub trait AccountStore: Send + Sync { + /// Create a previously absent account from canonical genesis. + fn create_account(&self, genesis: AccountGenesis) -> StoreFuture<'_, AccountSnapshot>; + + /// Load an account, distinguishing absence from authenticated-storage corruption. + fn load_account(&self, account_id: AccountId) -> StoreFuture<'_, Option>; + + /// Validate and atomically commit one event under an exact complete revision CAS. + fn commit_event( + &self, + expected_revision: AccountRevision, + event: AuthorizedEvent, + ) -> StoreFuture<'_, CommitReceipt>; + + /// Atomically validate and commit a bounded set of reordered or duplicate event envelopes. + fn commit_events( + &self, + expected_revision: AccountRevision, + events: Vec, + ) -> StoreFuture<'_, BatchCommitReceipt>; + + /// Verify and atomically retain one checkpoint under the exact current account revision. + fn commit_checkpoint( + &self, + expected_revision: AccountRevision, + checkpoint: VerifiedCheckpoint, + ) -> StoreFuture<'_, CheckpointCommitReceipt>; + + /// Return one bounded authenticated page from the durable checkpoint journal. + fn checkpoint_history( + &self, + account_id: AccountId, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> StoreFuture<'_, CheckpointJournalPage>; + + /// Return one bounded deterministic event page frozen to an exact complete source revision. + fn event_history( + &self, + source_revision: AccountRevision, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> StoreFuture<'_, EventHistoryPage>; + + /// Atomically claim a bounded batch of ready or expired effects. + fn claim_effects( + &self, + account_id: AccountId, + request: ClaimEffects, + ) -> StoreFuture<'_, Vec>; + + /// Mark a claimed effect completed, idempotently under the same lease. + fn complete_effect( + &self, + account_id: AccountId, + effect_id: EffectId, + lease_id: LeaseId, + completed_at: Timestamp, + ) -> StoreFuture<'_, ()>; + + /// Return a claimed effect to an explicit retry time and retain its typed failure. + fn retry_effect( + &self, + account_id: AccountId, + effect_id: EffectId, + lease_id: LeaseId, + retry_at: Timestamp, + failure: EffectFailure, + ) -> StoreFuture<'_, ()>; + + /// Persist one revision-bound rotation and complete its claimed mandatory effect atomically. + fn commit_group_key_rotation( + &self, + effect_id: EffectId, + lease_id: LeaseId, + rotation: GroupKeyRotation, + completed_at: Timestamp, + ) -> StoreFuture<'_, StoredGroupKeyRotation>; + + /// Gate a protected write on the exact revision and completed current-epoch rotation. + fn authorize_protected_write( + &self, + expected_revision: AccountRevision, + application_id: ApplicationId, + group_id: GroupId, + ) -> StoreFuture<'_, ()>; +} + +/// In-memory atomic store used by local-only deployments and conformance tests. +#[derive(Debug, Clone, Default)] +pub struct MemoryAccountStore { + accounts: Arc>>, +} + +impl MemoryAccountStore { + /// Create an empty in-memory account store. + pub fn new() -> Self { + Self::default() + } + + fn lock_accounts( + &self, + ) -> Result>, IdentityError> { + self.accounts + .lock() + .map_err(|_| IdentityError::StorageCorruption) + } +} + +impl AccountStore for MemoryAccountStore { + fn create_account(&self, genesis: AccountGenesis) -> StoreFuture<'_, AccountSnapshot> { + Box::pin(async move { + let account_id = genesis.account_id()?; + let mut accounts = self.lock_accounts()?; + if accounts.contains_key(&account_id) { + return Err(IdentityError::InvalidRelationship { + resource: "account store duplicate genesis", + }); + } + let stored = StoredAccount { + projection: AccountState::from_genesis(&genesis)?, + genesis, + events: BTreeMap::new(), + event_journal: Vec::new(), + outbox: BTreeMap::new(), + group_key_rotations: BTreeMap::new(), + checkpoints: BTreeMap::new(), + checkpoint_journal: Vec::new(), + }; + let snapshot = stored.snapshot()?; + accounts.insert(account_id, stored); + Ok(snapshot) + }) + } + + fn load_account(&self, account_id: AccountId) -> StoreFuture<'_, Option> { + Box::pin(async move { + let accounts = self.lock_accounts()?; + accounts + .get(&account_id) + .map(StoredAccount::snapshot) + .transpose() + }) + } + + fn commit_event( + &self, + expected_revision: AccountRevision, + event: AuthorizedEvent, + ) -> StoreFuture<'_, CommitReceipt> { + Box::pin(async move { + let account_id = expected_revision.account_id(); + let mut accounts = self.lock_accounts()?; + let current = accounts + .get(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let mut staged = current.clone(); + let receipt = staged.commit_event(&expected_revision, event)?; + accounts.insert(account_id, staged); + Ok(receipt) + }) + } + + fn commit_events( + &self, + expected_revision: AccountRevision, + events: Vec, + ) -> StoreFuture<'_, BatchCommitReceipt> { + Box::pin(async move { + let account_id = expected_revision.account_id(); + let mut accounts = self.lock_accounts()?; + let current = accounts + .get(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let mut staged = current.clone(); + let receipt = staged.commit_events(&expected_revision, events)?; + accounts.insert(account_id, staged); + Ok(receipt) + }) + } + + fn commit_checkpoint( + &self, + expected_revision: AccountRevision, + checkpoint: VerifiedCheckpoint, + ) -> StoreFuture<'_, CheckpointCommitReceipt> { + Box::pin(async move { + let account_id = expected_revision.account_id(); + let mut accounts = self.lock_accounts()?; + let account = + accounts + .get_mut(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let mut staged = account.clone(); + let receipt = staged.commit_checkpoint(&expected_revision, checkpoint)?; + *account = staged; + Ok(receipt) + }) + } + + fn checkpoint_history( + &self, + account_id: AccountId, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> StoreFuture<'_, CheckpointJournalPage> { + Box::pin(async move { + let accounts = self.lock_accounts()?; + let account = accounts + .get(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + account.checkpoint_history(after_cursor, maximum_records, maximum_bytes) + }) + } + + fn event_history( + &self, + source_revision: AccountRevision, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> StoreFuture<'_, EventHistoryPage> { + Box::pin(async move { + let accounts = self.lock_accounts()?; + let account = accounts.get(&source_revision.account_id()).ok_or( + IdentityError::InvalidRelationship { + resource: "account store missing account", + }, + )?; + account.event_history( + &source_revision, + after_cursor, + maximum_records, + maximum_bytes, + ) + }) + } + + fn claim_effects( + &self, + account_id: AccountId, + request: ClaimEffects, + ) -> StoreFuture<'_, Vec> { + Box::pin(async move { + let mut accounts = self.lock_accounts()?; + let account = + accounts + .get_mut(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + + account.claim_effects(request) + }) + } + + fn complete_effect( + &self, + account_id: AccountId, + effect_id: EffectId, + lease_id: LeaseId, + completed_at: Timestamp, + ) -> StoreFuture<'_, ()> { + Box::pin(async move { + let mut accounts = self.lock_accounts()?; + let account = + accounts + .get_mut(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + account.complete_effect(effect_id, lease_id, completed_at) + }) + } + + fn retry_effect( + &self, + account_id: AccountId, + effect_id: EffectId, + lease_id: LeaseId, + retry_at: Timestamp, + failure: EffectFailure, + ) -> StoreFuture<'_, ()> { + Box::pin(async move { + let mut accounts = self.lock_accounts()?; + let account = + accounts + .get_mut(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let exhausted = account.retry_effect(effect_id, lease_id, retry_at, failure)?; + if exhausted { + return Err(IdentityError::RetryExhausted); + } + Ok(()) + }) + } + + fn commit_group_key_rotation( + &self, + effect_id: EffectId, + lease_id: LeaseId, + rotation: GroupKeyRotation, + completed_at: Timestamp, + ) -> StoreFuture<'_, StoredGroupKeyRotation> { + Box::pin(async move { + let account_id = rotation.account_id(); + let mut accounts = self.lock_accounts()?; + let current = accounts + .get(&account_id) + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let mut staged = current.clone(); + let record = + staged.commit_group_key_rotation(effect_id, lease_id, rotation, completed_at)?; + accounts.insert(account_id, staged); + Ok(record) + }) + } + + fn authorize_protected_write( + &self, + expected_revision: AccountRevision, + application_id: ApplicationId, + group_id: GroupId, + ) -> StoreFuture<'_, ()> { + Box::pin(async move { + let accounts = self.lock_accounts()?; + let account = accounts.get(&expected_revision.account_id()).ok_or( + IdentityError::InvalidRelationship { + resource: "account store missing account", + }, + )?; + account.authorize_protected_write(&expected_revision, application_id, group_id) + }) + } +} + +pub(crate) fn derive_effect_id( + account_id: AccountId, + effect: ProjectionEffect, +) -> Result { + let mut hasher = blake3::Hasher::new_derive_key("KRIKOS-ID/projection-effect/v1"); + hasher.update(&account_id.to_canonical_bytes()?); + match effect { + ProjectionEffect::PublishAccountEvent { event_id } => { + hasher.update(&1_u16.to_be_bytes()); + hasher.update(&event_id.to_canonical_bytes()?); + } + ProjectionEffect::RotateGroupKeys { event_id, epoch } => { + hasher.update(&2_u16.to_be_bytes()); + hasher.update(&event_id.to_canonical_bytes()?); + hasher.update(&epoch.get().to_be_bytes()); + } + ProjectionEffect::NotifyAccountChanged { event_id } => { + hasher.update(&3_u16.to_be_bytes()); + hasher.update(&event_id.to_canonical_bytes()?); + } + ProjectionEffect::NotifyForkDetected { event_id } => { + hasher.update(&4_u16.to_be_bytes()); + hasher.update(&event_id.to_canonical_bytes()?); + } + } + Ok(EffectId(*hasher.finalize().as_bytes())) +} + +fn canonical_event_order( + events: Vec, +) -> Result, IdentityError> { + let mut keyed = events + .into_iter() + .map(|event| { + Ok(( + event.body().sequence(), + event.event_id()?, + event.event_authorization_id()?, + event, + )) + }) + .collect::, IdentityError>>()?; + keyed.sort_unstable_by_key(|(sequence, event_id, authorization_id, _)| { + (*sequence, *event_id, *authorization_id) + }); + Ok(keyed.into_iter().map(|(_, _, _, event)| event).collect()) +} + +fn insert_effects( + outbox: &mut BTreeMap, + account_id: AccountId, + effects: &[ProjectionEffect], +) -> Result<(), IdentityError> { + for effect in effects { + let id = derive_effect_id(account_id, *effect)?; + outbox.entry(id).or_insert(EffectRecord { + id, + account_id, + effect: *effect, + state: EffectState::Pending(PendingEffect::Scheduled(Timestamp::from_unix_millis(0))), + attempt_count: 0, + last_failure: None, + }); + } + Ok(()) +} diff --git a/protocols/krikos-identity/src/store/redb.rs b/protocols/krikos-identity/src/store/redb.rs new file mode 100644 index 00000000000..79cd8f9d53c --- /dev/null +++ b/protocols/krikos-identity/src/store/redb.rs @@ -0,0 +1,708 @@ +//! Optional redb-backed canonical source-record store. + +use std::{path::Path, sync::Arc}; + +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; + +use super::{ + AccountSnapshot, AccountStore, BatchCommitReceipt, CheckpointCommitReceipt, + CheckpointJournalPage, ClaimEffects, CommitReceipt, EffectFailure, EffectId, EffectRecord, + EffectState, LeaseId, MAX_STORED_CHECKPOINTS, PendingEffect, StoreFuture, StoredAccount, + StoredCheckpoint, StoredGroupKeyRotation, derive_effect_id, +}; +use crate::{ + AccountGenesis, AccountId, AccountRevision, ApplicationId, AuthorizedEvent, CanonicalWire, + EventId, GroupId, GroupKeyEpoch, GroupKeyRotation, IdentityError, ProjectionEffect, + RecipientKeyWraps, SignedCheckpoint, Timestamp, VerifiedCheckpoint, + codec::{decode_wire, encode_wire}, + limits::{MAX_FORK_HEADS, MAX_RETRIES}, + schema::BoundedVec, +}; + +const ACCOUNT_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-identity-accounts-v1"); +const MAX_STORED_EVENT_ENVELOPES: usize = 65_536; +const MAX_STORED_EFFECTS: usize = MAX_STORED_EVENT_ENVELOPES * 4; +const MAX_ACCOUNT_RECORD_BYTES: usize = 256 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct StoredAccountWire { + version: u16, + genesis: AccountGenesis, + events: BoundedVec, + event_journal: BoundedVec, + effects: BoundedVec, + rotations: BoundedVec, + checkpoints: BoundedVec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct StoredCheckpointWire { + checkpoint: SignedCheckpoint, + transition_event: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct EffectWire { + id: [u8; 32], + account_id: AccountId, + effect_code: u16, + event_id: EventId, + epoch: Option, + state_code: u16, + state_at: Timestamp, + lease_id: Option<[u8; 16]>, + attempt_count: u8, + failure: Option<(u16, u16)>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct RotationWire { + application_id: ApplicationId, + group_id: GroupId, + authorizing_account_epoch: crate::Epoch, + group_key_epoch: GroupKeyEpoch, + revision_heads: BoundedVec, + recipient_key_wraps: RecipientKeyWraps, +} + +impl StoredAccountWire { + fn from_stored(stored: &StoredAccount) -> Result { + Ok(Self { + version: 2, + genesis: stored.genesis.clone(), + events: BoundedVec::new( + "stored account event envelopes", + stored.events.values().cloned().collect(), + )?, + event_journal: BoundedVec::new( + "stored account event journal", + stored.event_journal.clone(), + )?, + effects: BoundedVec::new( + "stored account effects", + stored + .outbox + .values() + .map(EffectWire::from_record) + .collect::, IdentityError>>()?, + )?, + rotations: BoundedVec::new( + "stored group key rotations", + stored + .group_key_rotations + .values() + .map(RotationWire::from_record) + .collect::, IdentityError>>()?, + )?, + checkpoints: BoundedVec::new( + "stored account checkpoints", + stored + .checkpoint_journal + .iter() + .map(|checkpoint_id| { + stored + .checkpoints + .get(checkpoint_id) + .map(|record| StoredCheckpointWire { + checkpoint: record.checkpoint.clone(), + transition_event: record.transition_event.clone(), + }) + .ok_or(IdentityError::StorageCorruption) + }) + .collect::, IdentityError>>()?, + )?, + }) + } + + fn into_stored(self, expected_account_id: AccountId) -> Result { + if self.version != 2 || self.genesis.account_id()? != expected_account_id { + return Err(IdentityError::StorageCorruption); + } + let mut events = std::collections::BTreeMap::new(); + for event in self.events.into_vec() { + if event.body().account_id() != expected_account_id { + return Err(IdentityError::StorageCorruption); + } + let authorization_id = event.event_authorization_id()?; + if events.insert(authorization_id, event).is_some() { + return Err(IdentityError::StorageCorruption); + } + } + let mut outbox = std::collections::BTreeMap::new(); + for wire in self.effects.into_vec() { + let record = wire.into_record(expected_account_id)?; + if outbox.insert(record.id, record).is_some() { + return Err(IdentityError::StorageCorruption); + } + } + let mut group_key_rotations = std::collections::BTreeMap::new(); + for wire in self.rotations.into_vec() { + let record = wire.into_record(expected_account_id)?; + let key = (record.application_id, record.group_id); + if group_key_rotations.insert(key, record).is_some() { + return Err(IdentityError::StorageCorruption); + } + } + let mut checkpoints = std::collections::BTreeMap::new(); + let mut checkpoint_journal = Vec::new(); + for wire in self.checkpoints.into_vec() { + if wire.checkpoint.body().account_id() != expected_account_id { + return Err(IdentityError::StorageCorruption); + } + let checkpoint_id = wire.checkpoint.checkpoint_id()?; + let retained = StoredCheckpoint { + checkpoint: wire.checkpoint, + transition_event: wire.transition_event, + }; + if checkpoints.insert(checkpoint_id, retained).is_some() { + return Err(IdentityError::StorageCorruption); + } + checkpoint_journal.push(checkpoint_id); + } + let mut stored = StoredAccount { + projection: crate::AccountState::from_genesis(&self.genesis)?, + genesis: self.genesis, + events, + event_journal: self.event_journal.into_vec(), + outbox, + group_key_rotations, + checkpoints, + checkpoint_journal, + }; + let (projection, required_effects) = stored.rebuild_projection_and_effects()?; + if required_effects.len() != stored.outbox.len() + || required_effects.iter().any(|(id, effect)| { + stored + .outbox + .get(id) + .is_none_or(|record| record.effect != *effect) + }) + { + return Err(IdentityError::StorageCorruption); + } + stored.projection = projection; + stored.validate_checkpoint_journal()?; + let _ = stored.snapshot()?; + Ok(stored) + } +} + +impl RotationWire { + fn from_record(record: &StoredGroupKeyRotation) -> Result { + Ok(Self { + application_id: record.application_id, + group_id: record.group_id, + authorizing_account_epoch: record.authorizing_account_epoch, + group_key_epoch: record.group_key_epoch, + revision_heads: BoundedVec::new( + "stored group rotation revision heads", + record.revision_heads.clone(), + )?, + recipient_key_wraps: record.recipient_key_wraps.clone(), + }) + } + + fn into_record(self, account_id: AccountId) -> Result { + for wrap in self.recipient_key_wraps.as_slice() { + let header = wrap.header(); + if header.account_id() != account_id + || header.application_id() != self.application_id + || header.group_id() != self.group_id + || header.authorizing_account_epoch() != self.authorizing_account_epoch + || header.group_key_epoch() != self.group_key_epoch + { + return Err(IdentityError::StorageCorruption); + } + } + Ok(StoredGroupKeyRotation { + account_id, + application_id: self.application_id, + group_id: self.group_id, + authorizing_account_epoch: self.authorizing_account_epoch, + group_key_epoch: self.group_key_epoch, + revision_heads: self.revision_heads.into_vec(), + recipient_key_wraps: self.recipient_key_wraps, + }) + } +} + +impl EffectWire { + fn from_record(record: &EffectRecord) -> Result { + let (effect_code, event_id, epoch) = match record.effect { + ProjectionEffect::PublishAccountEvent { event_id } => (1, event_id, None), + ProjectionEffect::RotateGroupKeys { event_id, epoch } => (2, event_id, Some(epoch)), + ProjectionEffect::NotifyAccountChanged { event_id } => (3, event_id, None), + ProjectionEffect::NotifyForkDetected { event_id } => (4, event_id, None), + }; + let (state_code, state_at, lease_id) = match record.state { + EffectState::Pending(PendingEffect::Scheduled(at)) => (1, at, None), + EffectState::Pending(PendingEffect::Exhausted(at)) => (2, at, None), + EffectState::Claimed { + lease_id, + leased_until, + } => (3, leased_until, Some(*lease_id.as_bytes())), + EffectState::Completed { + lease_id, + completed_at, + } => (4, completed_at, Some(*lease_id.as_bytes())), + }; + let failure = record.last_failure.map(|failure| match failure { + EffectFailure::Transient(code) => (1, code), + EffectFailure::Permanent(code) => (2, code), + }); + Ok(Self { + id: *record.id.as_bytes(), + account_id: record.account_id, + effect_code, + event_id, + epoch, + state_code, + state_at, + lease_id, + attempt_count: record.attempt_count, + failure, + }) + } + + fn into_record(self, expected_account_id: AccountId) -> Result { + if self.account_id != expected_account_id || self.attempt_count > MAX_RETRIES { + return Err(IdentityError::StorageCorruption); + } + let effect = match (self.effect_code, self.epoch) { + (1, None) => ProjectionEffect::PublishAccountEvent { + event_id: self.event_id, + }, + (2, Some(epoch)) => ProjectionEffect::RotateGroupKeys { + event_id: self.event_id, + epoch, + }, + (3, None) => ProjectionEffect::NotifyAccountChanged { + event_id: self.event_id, + }, + (4, None) => ProjectionEffect::NotifyForkDetected { + event_id: self.event_id, + }, + _ => return Err(IdentityError::StorageCorruption), + }; + let state = match (self.state_code, self.lease_id) { + (1, None) => EffectState::Pending(PendingEffect::Scheduled(self.state_at)), + (2, None) => EffectState::Pending(PendingEffect::Exhausted(self.state_at)), + (3, Some(lease)) => EffectState::Claimed { + lease_id: LeaseId::new(lease).map_err(|_| IdentityError::StorageCorruption)?, + leased_until: self.state_at, + }, + (4, Some(lease)) => EffectState::Completed { + lease_id: LeaseId::new(lease).map_err(|_| IdentityError::StorageCorruption)?, + completed_at: self.state_at, + }, + _ => return Err(IdentityError::StorageCorruption), + }; + let last_failure = match self.failure { + None => None, + Some((1, code)) => { + Some(EffectFailure::transient(code).map_err(|_| IdentityError::StorageCorruption)?) + } + Some((2, code)) => { + Some(EffectFailure::permanent(code).map_err(|_| IdentityError::StorageCorruption)?) + } + Some(_) => return Err(IdentityError::StorageCorruption), + }; + let id = EffectId(self.id); + if derive_effect_id(expected_account_id, effect)? != id { + return Err(IdentityError::StorageCorruption); + } + Ok(EffectRecord { + id, + account_id: expected_account_id, + effect, + state, + attempt_count: self.attempt_count, + last_failure, + }) + } +} + +/// redb-backed atomic canonical source-record store. +#[derive(Debug, Clone)] +pub struct RedbAccountStore { + database: Arc, +} + +impl RedbAccountStore { + /// Open or create a database and authenticate every retained account projection. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + crate::redb_guard::validate_existing_redb_file(path)?; + let database = Database::create(path).map_err(|_| IdentityError::StorageCorruption)?; + { + let write = database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let _ = write + .open_table(ACCOUNT_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + } + let store = Self { + database: Arc::new(database), + }; + store.validate_all()?; + Ok(store) + } + + fn validate_all(&self) -> Result<(), IdentityError> { + let read = self + .database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let table = read + .open_table(ACCOUNT_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let iterator = table.iter().map_err(|_| IdentityError::StorageCorruption)?; + for entry in iterator { + let (key, value) = entry.map_err(|_| IdentityError::StorageCorruption)?; + let account_id = AccountId::from_canonical_bytes(key.value()) + .map_err(|_| IdentityError::StorageCorruption)?; + let _ = decode_stored(account_id, value.value())?; + } + Ok(()) + } + + fn load_sync(&self, account_id: AccountId) -> Result, IdentityError> { + let key = account_id.to_canonical_bytes()?; + let read = self + .database + .begin_read() + .map_err(|_| IdentityError::StorageCorruption)?; + let table = read + .open_table(ACCOUNT_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + table + .get(key.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .map(|value| decode_stored(account_id, value.value())) + .transpose() + } + + fn write_stored( + table: &mut redb::Table<'_, &[u8], &[u8]>, + account_id: AccountId, + stored: &StoredAccount, + ) -> Result<(), IdentityError> { + let key = account_id.to_canonical_bytes()?; + let bytes = encode_stored(stored)?; + table + .insert(key.as_slice(), bytes.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(()) + } +} + +impl AccountStore for RedbAccountStore { + fn create_account(&self, genesis: AccountGenesis) -> StoreFuture<'_, AccountSnapshot> { + Box::pin(async move { + let account_id = genesis.account_id()?; + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + { + let mut table = write + .open_table(ACCOUNT_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let key = account_id.to_canonical_bytes()?; + if table + .get(key.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .is_some() + { + return Err(IdentityError::InvalidRelationship { + resource: "account store duplicate genesis", + }); + } + let stored = StoredAccount { + projection: crate::AccountState::from_genesis(&genesis)?, + genesis, + events: std::collections::BTreeMap::new(), + event_journal: Vec::new(), + outbox: std::collections::BTreeMap::new(), + group_key_rotations: std::collections::BTreeMap::new(), + checkpoints: std::collections::BTreeMap::new(), + checkpoint_journal: Vec::new(), + }; + let snapshot = stored.snapshot()?; + Self::write_stored(&mut table, account_id, &stored)?; + drop(table); + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(snapshot) + } + }) + } + + fn load_account(&self, account_id: AccountId) -> StoreFuture<'_, Option> { + Box::pin(async move { + self.load_sync(account_id)? + .map(|stored| stored.snapshot()) + .transpose() + }) + } + + fn commit_event( + &self, + expected_revision: AccountRevision, + event: AuthorizedEvent, + ) -> StoreFuture<'_, CommitReceipt> { + Box::pin(async move { + let account_id = expected_revision.account_id(); + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let receipt; + { + let mut table = write + .open_table(ACCOUNT_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let key = account_id.to_canonical_bytes()?; + let value = table + .get(key.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let mut stored = decode_stored(account_id, value.value())?; + drop(value); + receipt = stored.commit_event(&expected_revision, event)?; + Self::write_stored(&mut table, account_id, &stored)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(receipt) + }) + } + + fn commit_events( + &self, + expected_revision: AccountRevision, + events: Vec, + ) -> StoreFuture<'_, BatchCommitReceipt> { + Box::pin(async move { + let account_id = expected_revision.account_id(); + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let receipt; + { + let mut table = write + .open_table(ACCOUNT_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let key = account_id.to_canonical_bytes()?; + let value = table + .get(key.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let mut stored = decode_stored(account_id, value.value())?; + drop(value); + receipt = stored.commit_events(&expected_revision, events)?; + Self::write_stored(&mut table, account_id, &stored)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(receipt) + }) + } + + fn commit_checkpoint( + &self, + expected_revision: AccountRevision, + checkpoint: VerifiedCheckpoint, + ) -> StoreFuture<'_, CheckpointCommitReceipt> { + Box::pin(async move { + let account_id = expected_revision.account_id(); + self.update_account(account_id, |stored| { + stored.commit_checkpoint(&expected_revision, checkpoint) + }) + }) + } + + fn checkpoint_history( + &self, + account_id: AccountId, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> StoreFuture<'_, CheckpointJournalPage> { + Box::pin(async move { + let stored = self + .load_sync(account_id)? + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + stored.checkpoint_history(after_cursor, maximum_records, maximum_bytes) + }) + } + + fn event_history( + &self, + source_revision: AccountRevision, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> StoreFuture<'_, super::EventHistoryPage> { + Box::pin(async move { + let stored = self.load_sync(source_revision.account_id())?.ok_or( + IdentityError::InvalidRelationship { + resource: "account store missing account", + }, + )?; + stored.event_history( + &source_revision, + after_cursor, + maximum_records, + maximum_bytes, + ) + }) + } + + fn claim_effects( + &self, + account_id: AccountId, + request: ClaimEffects, + ) -> StoreFuture<'_, Vec> { + Box::pin( + async move { self.update_account(account_id, |stored| stored.claim_effects(request)) }, + ) + } + + fn complete_effect( + &self, + account_id: AccountId, + effect_id: EffectId, + lease_id: LeaseId, + completed_at: Timestamp, + ) -> StoreFuture<'_, ()> { + Box::pin(async move { + self.update_account(account_id, |stored| { + stored.complete_effect(effect_id, lease_id, completed_at) + }) + }) + } + + fn retry_effect( + &self, + account_id: AccountId, + effect_id: EffectId, + lease_id: LeaseId, + retry_at: Timestamp, + failure: EffectFailure, + ) -> StoreFuture<'_, ()> { + Box::pin(async move { + let exhausted = self.update_account(account_id, |stored| { + stored.retry_effect(effect_id, lease_id, retry_at, failure) + })?; + if exhausted { + return Err(IdentityError::RetryExhausted); + } + Ok(()) + }) + } + + fn commit_group_key_rotation( + &self, + effect_id: EffectId, + lease_id: LeaseId, + rotation: GroupKeyRotation, + completed_at: Timestamp, + ) -> StoreFuture<'_, StoredGroupKeyRotation> { + Box::pin(async move { + let account_id = rotation.account_id(); + self.update_account(account_id, |stored| { + stored.commit_group_key_rotation(effect_id, lease_id, rotation, completed_at) + }) + }) + } + + fn authorize_protected_write( + &self, + expected_revision: AccountRevision, + application_id: ApplicationId, + group_id: GroupId, + ) -> StoreFuture<'_, ()> { + Box::pin(async move { + let stored = self.load_sync(expected_revision.account_id())?.ok_or( + IdentityError::InvalidRelationship { + resource: "account store missing account", + }, + )?; + stored.authorize_protected_write(&expected_revision, application_id, group_id) + }) + } +} + +impl RedbAccountStore { + fn update_account( + &self, + account_id: AccountId, + update: impl FnOnce(&mut StoredAccount) -> Result, + ) -> Result { + let write = self + .database + .begin_write() + .map_err(|_| IdentityError::StorageCorruption)?; + let output; + { + let mut table = write + .open_table(ACCOUNT_TABLE) + .map_err(|_| IdentityError::StorageCorruption)?; + let key = account_id.to_canonical_bytes()?; + let value = table + .get(key.as_slice()) + .map_err(|_| IdentityError::StorageCorruption)? + .ok_or(IdentityError::InvalidRelationship { + resource: "account store missing account", + })?; + let mut stored = decode_stored(account_id, value.value())?; + drop(value); + output = update(&mut stored)?; + Self::write_stored(&mut table, account_id, &stored)?; + } + write + .commit() + .map_err(|_| IdentityError::StorageCorruption)?; + Ok(output) + } +} + +fn encode_stored(stored: &StoredAccount) -> Result, IdentityError> { + let wire = StoredAccountWire::from_stored(stored)?; + let bytes = encode_wire(&wire)?; + if bytes.len() > MAX_ACCOUNT_RECORD_BYTES { + return Err(IdentityError::limit( + "stored account source bytes", + bytes.len(), + MAX_ACCOUNT_RECORD_BYTES, + )); + } + Ok(bytes) +} + +fn decode_stored(account_id: AccountId, bytes: &[u8]) -> Result { + if bytes.len() > MAX_ACCOUNT_RECORD_BYTES { + return Err(IdentityError::StorageCorruption); + } + let wire = + decode_wire::(bytes).map_err(|_| IdentityError::StorageCorruption)?; + wire.into_stored(account_id) + .map_err(|_| IdentityError::StorageCorruption) +} diff --git a/protocols/krikos-identity/src/sync.rs b/protocols/krikos-identity/src/sync.rs new file mode 100644 index 00000000000..2206a39dd79 --- /dev/null +++ b/protocols/krikos-identity/src/sync.rs @@ -0,0 +1,906 @@ +//! Canonical bounded account synchronization wire contracts. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, de}; + +use crate::{ + AccountId, AccountRevision, AccountStore, AuthorizedEvent, BatchCommitReceipt, CanonicalWire, + EventHistoryCursor, EventId, IdentityError, ProtocolVersion, StoreFuture, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, + limits::{ + MAX_EVENTS_PER_SYNC_BATCH, MAX_FORK_HEADS, MAX_SYNC_FRAME_BYTES, MAX_SYNC_SESSION_BYTES, + }, + schema::BoundedVec, +}; + +/// Atomically reconcile one decoded frame after deterministic event reordering. +/// +/// Every event is state-validated by the store before any source record or effect becomes +/// visible. Peer-advertised heads are availability metadata only and never bypass authority. +pub fn reconcile_sync_frame<'a, S: AccountStore + ?Sized>( + store: &'a S, + expected_revision: AccountRevision, + frame: &'a SyncFrame, +) -> StoreFuture<'a, BatchCommitReceipt> { + Box::pin(async move { + if frame.account_id() != expected_revision.account_id() { + return Err(IdentityError::AccountMismatch); + } + store + .commit_events(expected_revision, frame.events().to_vec()) + .await + }) +} + +/// Serve one bounded synchronization request from authenticated durable source history. +/// +/// A first request freezes the store's exact complete revision. Every continuation authenticates +/// that revision and its next deterministic item, so later appends cannot leak into the resumed +/// session. Peer-advertised heads are used only to detect immediate convergence. +pub fn serve_sync_request<'a, S: AccountStore + ?Sized>( + store: &'a S, + cursor_key: &'a CursorKey, + request: &'a SyncRequest, +) -> StoreFuture<'a, SyncResponse> { + Box::pin(async move { + let request_bytes = request.to_canonical_bytes()?.len(); + serve_sync_request_with_meter( + store, + cursor_key, + request, + request_bytes, + canonical_sync_response_bytes, + ) + .await + }) +} + +pub(crate) type SyncResponseMeter = fn(&SyncResponse) -> Result; + +#[derive(Clone, Copy)] +struct SyncResponseBudget { + previously_delivered: usize, + current_request_bytes: usize, + maximum_bytes: usize, + response_meter: SyncResponseMeter, +} + +pub(crate) fn serve_sync_request_with_meter<'a, S: AccountStore + ?Sized>( + store: &'a S, + cursor_key: &'a CursorKey, + request: &'a SyncRequest, + current_request_bytes: usize, + response_meter: SyncResponseMeter, +) -> StoreFuture<'a, SyncResponse> { + Box::pin(async move { + let snapshot = store.load_account(request.account_id()).await?.ok_or( + IdentityError::InvalidRelationship { + resource: "account store missing account", + }, + )?; + let (source_revision, after_cursor, delivered_bytes) = + if let Some(cursor) = request.continuation() { + cursor.verify(cursor_key)?; + if cursor.account_id() != request.account_id() { + return Err(IdentityError::AccountMismatch); + } + let source_revision = crate::AccountRevision::from_frozen_heads( + cursor.account_id(), + cursor.source_heads().to_vec(), + )?; + let next_item = cursor.next_item(); + let after_cursor = if next_item == 0 { + None + } else { + Some(EventHistoryCursor::from_verified_sync( + source_revision.clone(), + next_item + .checked_sub(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "sync continuation cursor", + })?, + )) + }; + ( + source_revision, + after_cursor, + usize::try_from(cursor.delivered_bytes()).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "sync continuation delivered bytes", + } + })?, + ) + } else { + (snapshot.revision().clone(), None, 0) + }; + let response_budget = SyncResponseBudget { + previously_delivered: delivered_bytes, + current_request_bytes, + maximum_bytes: request.max_frame_bytes(), + response_meter, + }; + + if request.continuation().is_none() && request.known_heads() == source_revision.heads() { + return bounded_sync_response( + SyncResponse::complete(request.account_id(), source_revision.heads().to_vec())?, + &response_budget, + ); + } + + let page = store + .event_history( + source_revision.clone(), + after_cursor, + request.max_events(), + crate::limits::MAX_HISTORY_PAGE_BYTES, + ) + .await?; + if page.source_revision() != &source_revision { + return Err(IdentityError::StorageCorruption); + } + if page.records().is_empty() { + return bounded_sync_response( + SyncResponse::complete(request.account_id(), source_revision.heads().to_vec())?, + &response_budget, + ); + } + + let mut records = page.records().to_vec(); + let mut has_more = page.next_cursor().is_some(); + loop { + let next_item = records + .last() + .map(|record| { + record + .cursor() + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "sync continuation cursor", + }) + }) + .transpose()?; + let events = records + .iter() + .map(|record| record.event().clone()) + .collect::>(); + let response = if has_more { + build_stable_sync_response( + cursor_key, + &source_revision, + next_item.ok_or(IdentityError::StorageCorruption)?, + &events, + &response_budget, + ) + } else { + SyncFrame::new( + request.account_id(), + source_revision.heads().to_vec(), + events, + None, + ) + .map(SyncResponse::frame) + }; + match response.and_then(|response| bounded_sync_response(response, &response_budget)) { + Ok(response) => return Ok(response), + Err(IdentityError::LimitExceeded { .. }) if records.len() > 1 => { + records.pop(); + has_more = true; + } + Err(error) => return Err(error), + } + } + }) +} + +fn build_stable_sync_response( + key: &CursorKey, + revision: &crate::AccountRevision, + next_item: u64, + events: &[AuthorizedEvent], + response_budget: &SyncResponseBudget, +) -> Result { + let mut delivered = response_budget + .previously_delivered + .checked_add(response_budget.current_request_bytes) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "sync session bytes", + })?; + for _ in 0..=u64::BITS { + let cursor = SyncCursor::issue( + key, + revision.account_id(), + revision.heads().to_vec(), + next_item, + delivered, + )?; + let frame = SyncFrame::new( + revision.account_id(), + revision.heads().to_vec(), + events.to_vec(), + Some(cursor.clone()), + )?; + let response = SyncResponse::frame(frame); + let encoded = response.to_canonical_bytes()?; + if encoded.len() > response_budget.maximum_bytes { + return Err(IdentityError::limit( + "sync response bytes", + encoded.len(), + response_budget.maximum_bytes, + )); + } + let response_bytes = (response_budget.response_meter)(&response)?; + let next_delivered = response_budget + .previously_delivered + .checked_add(response_budget.current_request_bytes) + .and_then(|consumed| consumed.checked_add(response_bytes)) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "sync session bytes", + })?; + let mut budget = SyncSessionBudget::new(); + budget.charge_bytes(response_budget.previously_delivered)?; + budget.charge_bytes(response_budget.current_request_bytes)?; + budget.charge_bytes(response_bytes)?; + if next_delivered == delivered { + return Ok(response); + } + delivered = next_delivered; + } + Err(IdentityError::InvalidProof) +} + +fn bounded_sync_response( + response: SyncResponse, + response_budget: &SyncResponseBudget, +) -> Result { + let encoded = response.to_canonical_bytes()?; + if encoded.len() > response_budget.maximum_bytes { + return Err(IdentityError::limit( + "sync response bytes", + encoded.len(), + response_budget.maximum_bytes, + )); + } + let response_bytes = (response_budget.response_meter)(&response)?; + let mut budget = SyncSessionBudget::new(); + budget.charge_bytes(response_budget.previously_delivered)?; + budget.charge_bytes(response_budget.current_request_bytes)?; + budget.charge_bytes(response_bytes)?; + Ok(response) +} + +fn canonical_sync_response_bytes(response: &SyncResponse) -> Result { + response.to_canonical_bytes().map(|bytes| bytes.len()) +} + +/// Secret cursor-authentication key held by the session issuer. +pub struct CursorKey([u8; 32]); + +impl CursorKey { + /// Validate a nonzero 256-bit cursor key. + pub fn new(bytes: [u8; 32]) -> Result { + if bytes == [0; 32] { + return Err(IdentityError::ZeroValue { + resource: "sync cursor key", + }); + } + Ok(Self(bytes)) + } +} + +impl fmt::Debug for CursorKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CursorKey()") + } +} + +fn sorted_heads( + mut heads: Vec, +) -> Result, IdentityError> { + heads.sort_unstable(); + BoundedVec::new("sync head set", heads).and_then(validate_sorted_heads) +} + +fn validate_sorted_heads( + heads: BoundedVec, +) -> Result, IdentityError> { + for pair in heads.as_slice().windows(2) { + if pair[0] == pair[1] { + return Err(IdentityError::DuplicateElement { + resource: "sync head set", + }); + } + if pair[0] > pair[1] { + return Err(IdentityError::NonCanonical); + } + } + Ok(heads) +} + +/// Key-authenticated continuation for one bounded synchronization session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SyncCursor { + protocol_version: ProtocolVersion, + account_id: AccountId, + source_heads: BoundedVec, + next_item: u64, + delivered_bytes: u64, + authenticator: [u8; 32], +} + +impl SyncCursor { + /// Issue a cursor bound to the exact account, source head set, and progress counters. + pub fn issue( + key: &CursorKey, + account_id: AccountId, + source_heads: Vec, + next_item: u64, + delivered_bytes: usize, + ) -> Result { + if delivered_bytes > MAX_SYNC_SESSION_BYTES { + return Err(IdentityError::limit( + "sync cursor delivered bytes", + delivered_bytes, + MAX_SYNC_SESSION_BYTES, + )); + } + let source_heads = sorted_heads(source_heads)?; + let delivered_bytes = + u64::try_from(delivered_bytes).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "sync cursor delivered bytes", + })?; + let authenticator = cursor_authenticator( + key, + account_id, + source_heads.as_slice(), + next_item, + delivered_bytes, + )?; + Ok(Self { + protocol_version: ProtocolVersion::V1, + account_id, + source_heads, + next_item, + delivered_bytes, + authenticator, + }) + } + + /// Verify that this continuation was issued under `key` for its exact fields. + pub fn verify(&self, key: &CursorKey) -> Result<(), IdentityError> { + let expected = cursor_authenticator( + key, + self.account_id, + self.source_heads.as_slice(), + self.next_item, + self.delivered_bytes, + )?; + if expected != self.authenticator { + return Err(IdentityError::InvalidProof); + } + Ok(()) + } + + /// Account being resumed. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact complete sorted source revision used when the cursor was issued. + pub fn source_heads(&self) -> &[EventId] { + self.source_heads.as_slice() + } + + /// Zero-based next source item. + pub const fn next_item(&self) -> u64 { + self.next_item + } + + /// Exact session bytes already consumed when this continuation was issued. + pub const fn delivered_bytes(&self) -> u64 { + self.delivered_bytes + } +} + +impl<'de> Deserialize<'de> for SyncCursor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + source_heads: BoundedVec, + next_item: u64, + delivered_bytes: u64, + authenticator: [u8; 32], + } + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + validate_sorted_heads(wire.source_heads.clone()).map_err(de::Error::custom)?; + if wire.delivered_bytes + > u64::try_from(MAX_SYNC_SESSION_BYTES).map_err(de::Error::custom)? + { + return Err(de::Error::custom(IdentityError::limit( + "sync cursor delivered bytes", + usize::try_from(wire.delivered_bytes).unwrap_or(usize::MAX), + MAX_SYNC_SESSION_BYTES, + ))); + } + Ok(Self { + protocol_version: wire.protocol_version, + account_id: wire.account_id, + source_heads: wire.source_heads, + next_item: wire.next_item, + delivered_bytes: wire.delivered_bytes, + authenticator: wire.authenticator, + }) + } +} + +impl CanonicalCodec for SyncCursor { + const RESOURCE: &'static str = "sync cursor bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +fn cursor_authenticator( + key: &CursorKey, + account_id: AccountId, + source_heads: &[EventId], + next_item: u64, + delivered_bytes: u64, +) -> Result<[u8; 32], IdentityError> { + let payload = encode_wire(&( + ProtocolVersion::V1, + account_id, + source_heads, + next_item, + delivered_bytes, + ))?; + Ok(*blake3::keyed_hash(&key.0, &payload).as_bytes()) +} + +/// Bounded account synchronization request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SyncRequest { + protocol_version: ProtocolVersion, + account_id: AccountId, + known_heads: BoundedVec, + continuation: Option, + max_events: u16, + max_frame_bytes: u32, +} + +impl SyncRequest { + /// Construct a request with explicit item and encoded-byte limits. + pub fn new( + account_id: AccountId, + known_heads: Vec, + continuation: Option, + max_events: usize, + max_frame_bytes: usize, + ) -> Result { + let known_heads = sorted_heads(known_heads)?; + if max_events == 0 || max_events > MAX_EVENTS_PER_SYNC_BATCH { + return Err(IdentityError::limit( + "sync request event limit", + max_events, + MAX_EVENTS_PER_SYNC_BATCH, + )); + } + if max_frame_bytes == 0 || max_frame_bytes > MAX_SYNC_FRAME_BYTES { + return Err(IdentityError::limit( + "sync request byte limit", + max_frame_bytes, + MAX_SYNC_FRAME_BYTES, + )); + } + if continuation + .as_ref() + .is_some_and(|cursor| cursor.account_id != account_id) + { + return Err(IdentityError::AccountMismatch); + } + Ok(Self { + protocol_version: ProtocolVersion::V1, + account_id, + known_heads, + continuation, + max_events: u16::try_from(max_events).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "sync request event limit", + } + })?, + max_frame_bytes: u32::try_from(max_frame_bytes).map_err(|_| { + IdentityError::ArithmeticOverflow { + resource: "sync request byte limit", + } + })?, + }) + } + + /// Requested account. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Complete sorted heads already held by the requester. + pub fn known_heads(&self) -> &[EventId] { + self.known_heads.as_slice() + } + + /// Optional authenticated continuation. + pub const fn continuation(&self) -> Option<&SyncCursor> { + self.continuation.as_ref() + } + + /// Maximum events accepted in the response frame. + pub const fn max_events(&self) -> usize { + self.max_events as usize + } + + /// Maximum encoded response frame bytes. + pub const fn max_frame_bytes(&self) -> usize { + self.max_frame_bytes as usize + } +} + +impl<'de> Deserialize<'de> for SyncRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + known_heads: BoundedVec, + continuation: Option, + max_events: u16, + max_frame_bytes: u32, + } + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + validate_sorted_heads(wire.known_heads.clone()).map_err(de::Error::custom)?; + let request = Self::new( + wire.account_id, + wire.known_heads.into_vec(), + wire.continuation, + usize::from(wire.max_events), + usize::try_from(wire.max_frame_bytes).map_err(de::Error::custom)?, + ) + .map_err(de::Error::custom)?; + Ok(request) + } +} + +impl CanonicalCodec for SyncRequest { + const RESOURCE: &'static str = "sync request bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +/// One atomic, bounded synchronization frame. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SyncFrame { + protocol_version: ProtocolVersion, + account_id: AccountId, + source_heads: BoundedVec, + events: BoundedVec, + continuation: Option, +} + +impl SyncFrame { + /// Construct a frame and enforce both event-count and exact encoded-byte limits. + pub fn new( + account_id: AccountId, + source_heads: Vec, + events: Vec, + continuation: Option, + ) -> Result { + let source_heads = sorted_heads(source_heads)?; + let events = BoundedVec::new("sync frame events", events)?; + if continuation.as_ref().is_some_and(|cursor| { + cursor.account_id != account_id + || cursor.source_heads.as_slice() != source_heads.as_slice() + }) { + return Err(IdentityError::InvalidRelationship { + resource: "sync frame continuation", + }); + } + let frame = Self { + protocol_version: ProtocolVersion::V1, + account_id, + source_heads, + events, + continuation, + }; + let encoded_len = encode_wire(&frame)?.len(); + if encoded_len > MAX_SYNC_FRAME_BYTES { + return Err(IdentityError::limit( + "sync frame bytes", + encoded_len, + MAX_SYNC_FRAME_BYTES, + )); + } + Ok(frame) + } + + /// Account whose source records are carried. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Complete sorted source revision. + pub fn source_heads(&self) -> &[EventId] { + self.source_heads.as_slice() + } + + /// Bounded canonical event envelopes. + pub fn events(&self) -> &[AuthorizedEvent] { + self.events.as_slice() + } + + /// Authenticated continuation when more source records remain. + pub const fn continuation(&self) -> Option<&SyncCursor> { + self.continuation.as_ref() + } +} + +impl<'de> Deserialize<'de> for SyncFrame { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + account_id: AccountId, + source_heads: BoundedVec, + events: BoundedVec, + continuation: Option, + } + let wire = Wire::deserialize(deserializer)?; + if wire.protocol_version != ProtocolVersion::V1 { + return Err(de::Error::custom(IdentityError::UnsupportedVersion { + version: wire.protocol_version.get(), + })); + } + validate_sorted_heads(wire.source_heads.clone()).map_err(de::Error::custom)?; + Self::new( + wire.account_id, + wire.source_heads.into_vec(), + wire.events.into_vec(), + wire.continuation, + ) + .map_err(de::Error::custom) + } +} + +impl CanonicalCodec for SyncFrame { + const RESOURCE: &'static str = "sync frame bytes"; + const MAX_ENCODED_BYTES: usize = MAX_SYNC_FRAME_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + decode_wire(bytes) + } +} + +const SYNC_RESPONSE_FRAME_CODE: u16 = 1; +const SYNC_RESPONSE_COMPLETE_CODE: u16 = 2; + +/// Versioned bounded synchronization response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SyncResponse { + protocol_version: ProtocolVersion, + response_code: u16, + frame: Option, + complete_account_id: Option, + complete_heads: Option>, +} + +impl SyncResponse { + /// Closed v1 codepoint for an event-bearing synchronization frame. + pub const FRAME_CODE: u16 = SYNC_RESPONSE_FRAME_CODE; + + /// Closed v1 codepoint for a convergence response carrying the final head set. + pub const COMPLETE_CODE: u16 = SYNC_RESPONSE_COMPLETE_CODE; + + /// Wrap one validated source-record frame. + pub const fn frame(frame: SyncFrame) -> Self { + Self { + protocol_version: ProtocolVersion::V1, + response_code: SYNC_RESPONSE_FRAME_CODE, + frame: Some(frame), + complete_account_id: None, + complete_heads: None, + } + } + + /// Report convergence at one complete sorted head set. + pub fn complete(account_id: AccountId, heads: Vec) -> Result { + Ok(Self { + protocol_version: ProtocolVersion::V1, + response_code: SYNC_RESPONSE_COMPLETE_CODE, + frame: None, + complete_account_id: Some(account_id), + complete_heads: Some(sorted_heads(heads)?), + }) + } + + /// Closed v1 response codepoint. + pub const fn code(&self) -> u16 { + self.response_code + } + + /// Frame payload, when this response carries source records. + pub const fn as_frame(&self) -> Option<&SyncFrame> { + self.frame.as_ref() + } + + /// Complete convergence head set and account, when present. + pub fn as_complete(&self) -> Option<(AccountId, &[EventId])> { + match (self.complete_account_id, self.complete_heads.as_ref()) { + (Some(account_id), Some(heads)) => Some((account_id, heads.as_slice())), + _ => None, + } + } + + fn validate(&self) -> Result<(), IdentityError> { + if self.protocol_version != ProtocolVersion::V1 { + return Err(IdentityError::UnsupportedVersion { + version: self.protocol_version.get(), + }); + } + match ( + self.response_code, + &self.frame, + self.complete_account_id, + &self.complete_heads, + ) { + (SYNC_RESPONSE_FRAME_CODE, Some(_), None, None) => Ok(()), + (SYNC_RESPONSE_COMPLETE_CODE, None, Some(_), Some(heads)) => { + validate_sorted_heads(heads.clone()).map(|_| ()) + } + (SYNC_RESPONSE_FRAME_CODE | SYNC_RESPONSE_COMPLETE_CODE, _, _, _) => { + Err(IdentityError::InvalidRelationship { + resource: "sync response payload", + }) + } + (code, _, _, _) => Err(IdentityError::UnsupportedCodepoint { + registry: "sync response", + code, + }), + } + } +} + +impl<'de> Deserialize<'de> for SyncResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + protocol_version: ProtocolVersion, + response_code: u16, + frame: Option, + complete_account_id: Option, + complete_heads: Option>, + } + let wire = Wire::deserialize(deserializer)?; + let response = Self { + protocol_version: wire.protocol_version, + response_code: wire.response_code, + frame: wire.frame, + complete_account_id: wire.complete_account_id, + complete_heads: wire.complete_heads, + }; + response.validate().map_err(de::Error::custom)?; + Ok(response) + } +} + +impl CanonicalCodec for SyncResponse { + const RESOURCE: &'static str = "sync response bytes"; + const MAX_ENCODED_BYTES: usize = MAX_SYNC_FRAME_BYTES; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(self) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + #[derive(Deserialize, Serialize)] + struct Wire { + protocol_version: u16, + response_code: u16, + frame: Option, + complete_account_id: Option, + complete_heads: Option>, + } + + let wire: Wire = decode_wire(bytes)?; + let response = Self { + protocol_version: ProtocolVersion::new(wire.protocol_version)?, + response_code: wire.response_code, + frame: wire.frame, + complete_account_id: wire.complete_account_id, + complete_heads: wire.complete_heads, + }; + response.validate()?; + Ok(response) + } +} + +/// Exact deterministic byte budget for one synchronization session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SyncSessionBudget { + consumed_bytes: usize, +} + +impl SyncSessionBudget { + /// Start an empty session budget. + pub const fn new() -> Self { + Self { consumed_bytes: 0 } + } + + /// Charge exact encoded bytes before processing or allocation. + pub fn charge_bytes(&mut self, encoded_bytes: usize) -> Result<(), IdentityError> { + let consumed = self.consumed_bytes.checked_add(encoded_bytes).ok_or( + IdentityError::ArithmeticOverflow { + resource: "sync session bytes", + }, + )?; + if consumed > MAX_SYNC_SESSION_BYTES { + return Err(IdentityError::limit( + "sync session bytes", + consumed, + MAX_SYNC_SESSION_BYTES, + )); + } + self.consumed_bytes = consumed; + Ok(()) + } + + /// Total exact encoded bytes charged so far. + pub const fn consumed_bytes(&self) -> usize { + self.consumed_bytes + } + + /// Remaining allowed encoded bytes. + pub const fn remaining_bytes(&self) -> usize { + MAX_SYNC_SESSION_BYTES - self.consumed_bytes + } +} + +impl Default for SyncSessionBudget { + fn default() -> Self { + Self::new() + } +} diff --git a/protocols/krikos-identity/src/transparency.rs b/protocols/krikos-identity/src/transparency.rs new file mode 100644 index 00000000000..19311503e08 --- /dev/null +++ b/protocols/krikos-identity/src/transparency.rs @@ -0,0 +1,763 @@ +//! Bounded in-memory provider-log generation and proof serving. + +use serde::Serialize; + +use crate::{ + AccountId, Extensions, IdentityError, InclusionReceipt, ProtocolSignature, ProviderDescriptor, + ProviderEquivocationEvidence, ProviderHeadBody, ProviderKeyVersion, ProviderLogEntryBody, + ProviderLogId, ProviderLogSubject, SignedProviderHead, Timestamp, + limits::{MAX_HISTORY_PAGE_EVENTS, MAX_MERKLE_LOG_LEAVES, MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES}, + merkle::{AppendOnlyMerkleLog, MerkleConsistencyProof}, +}; + +/// Signing boundary used by a provider log without exposing key storage to the log engine. +pub trait ProviderHeadSigner { + /// Produce a signature for the exact domain-separated message returned by + /// [`ProviderHeadBody::signing_bytes`]. + /// + /// Implementations must not publish the signature or a signed head as an + /// externally visible provider observation. The store durably records and + /// verifies the signed candidate before it promotes or exposes it. + fn sign_provider_head(&self, message: &[u8]) -> Result; +} + +/// Accepted observation class produced by a provider-log auditor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderHeadAuditDisposition { + /// First authenticated head accepted as this auditor's explicit generation anchor. + FirstObserved, + /// A larger tree was verified append-only from the retained head. + TreeAdvanced, + /// The same tree was signed again at a later provider observation time. + HeadRefreshed, + /// The exact authenticated head was already retained. + Replay, +} + +/// Stateful single-generation auditor that rejects rollback and durably retains equivocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderHeadAuditor { + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest: Option, + equivocation: Option, +} + +impl ProviderHeadAuditor { + /// Start auditing one explicitly selected provider/log generation boundary. + pub const fn new(provider: ProviderDescriptor, log_id: ProviderLogId) -> Self { + Self { + provider, + log_id, + latest: None, + equivocation: None, + } + } + + /// Provider descriptor whose exact key authenticates this audit stream. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Explicit log generation; changing it requires construction of a new auditor. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Latest accepted authenticated head, if any. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest.as_ref() + } + + /// First retained same-size/different-root proof, after which this auditor fails closed. + pub const fn equivocation_evidence(&self) -> Option<&ProviderEquivocationEvidence> { + self.equivocation.as_ref() + } + + /// Verify and retain one signed head without crossing an implicit key or log rotation. + pub fn observe( + &mut self, + head: SignedProviderHead, + consistency_proof: Option<&MerkleConsistencyProof>, + ) -> Result { + if self.equivocation.is_some() { + return Err(IdentityError::ProviderEquivocation); + } + head.verify(&self.provider)?; + if head.body().log_id() != self.log_id { + return Err(IdentityError::InvalidRelationship { + resource: "provider auditor log generation", + }); + } + let Some(previous) = self.latest.as_ref() else { + self.latest = Some(head); + return Ok(ProviderHeadAuditDisposition::FirstObserved); + }; + if head == *previous { + return Ok(ProviderHeadAuditDisposition::Replay); + } + if head.body().tree_size() == previous.body().tree_size() + && head.body().tree_root() != previous.body().tree_root() + { + self.equivocation = Some(ProviderEquivocationEvidence::new( + &self.provider, + previous.clone(), + head, + )?); + return Err(IdentityError::ProviderEquivocation); + } + if head.body().tree_size() < previous.body().tree_size() + || head.body().observed_at() < previous.body().observed_at() + { + return Err(IdentityError::ProviderRollback); + } + if head.body().tree_size() == previous.body().tree_size() { + if let Some(proof) = consistency_proof { + crate::verify_provider_head_progression(&self.provider, previous, &head, proof)?; + } + self.latest = Some(head); + return Ok(ProviderHeadAuditDisposition::HeadRefreshed); + } + crate::verify_provider_head_progression( + &self.provider, + previous, + &head, + consistency_proof.ok_or(IdentityError::InvalidProof)?, + )?; + self.latest = Some(head); + Ok(ProviderHeadAuditDisposition::TreeAdvanced) + } +} + +/// Opaque proof that an account checkpoint or proposal intent passed provider admission. +/// +/// Constructors remain crate-private: checkpoint and intent verification create this token only +/// after evaluating the applicable authenticated account pre-state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderLogAdmission { + account_id: AccountId, + subject: ProviderLogSubject, + required_observed_at: Option, + checkpoint_bundle: Option, +} + +impl ProviderLogAdmission { + pub(crate) fn checkpoint(bundle: crate::ProviderCheckpointBundle) -> Self { + let checkpoint = bundle.verified_checkpoint(); + Self { + account_id: checkpoint.checkpoint().body().account_id(), + subject: ProviderLogSubject::Checkpoint(checkpoint.checkpoint_id()), + required_observed_at: None, + checkpoint_bundle: Some(bundle), + } + } + + pub(crate) const fn event_intent( + account_id: AccountId, + proposal_id: crate::ProposalId, + ) -> Self { + Self { + account_id, + subject: ProviderLogSubject::EventIntent(proposal_id), + required_observed_at: None, + checkpoint_bundle: None, + } + } + + pub(crate) const fn guardian_recovery_intent( + account_id: AccountId, + proposal_id: crate::ProposalId, + observed_at: Timestamp, + ) -> Self { + Self { + account_id, + subject: ProviderLogSubject::EventIntent(proposal_id), + required_observed_at: Some(observed_at), + checkpoint_bundle: None, + } + } + + pub(crate) fn validate_observed_at(&self, observed_at: Timestamp) -> Result<(), IdentityError> { + if self + .required_observed_at + .is_some_and(|required| required != observed_at) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider admission observation time", + }); + } + Ok(()) + } + + pub(crate) const fn checkpoint_bundle(&self) -> Option<&crate::ProviderCheckpointBundle> { + self.checkpoint_bundle.as_ref() + } + + /// Account whose verified object may be appended. + pub const fn account_id(&self) -> AccountId { + self.account_id + } + + /// Exact checkpoint or threshold-approved proposal intent admitted by the verifier. + pub const fn subject(&self) -> ProviderLogSubject { + self.subject + } +} + +/// Verify a delayed proposal intent under its exact authenticated pre-state. +/// +/// The returned opaque token is the only public route for appending an event-intent leaf. It binds +/// the next sequence, complete predecessor set, resulting epoch, proposal ID, applicable delayed +/// rule, controller scopes, cryptographic signatures, and the applicable control or recovery +/// threshold before any provider observation can start the delay. +pub fn verify_event_intent_admission( + pre_state: &crate::AccountState, + body: &crate::EventBody, + approvals: &crate::EventIntentApprovals, +) -> Result { + let verified = crate::verifier::verify_event_intent(pre_state, body, approvals)?; + Ok(ProviderLogAdmission::event_intent( + verified.account_id(), + verified.proposal_id(), + )) +} + +/// Verify guardian recovery authority at one exact provider observation time. +/// +/// Unlike controller intent admission, guardian authority is embedded in the recovery body and no +/// unrelated controller-intent approval set is accepted. The returned one-shot capability is bound +/// to `observed_at`; provider append rejects any substituted observation time before mutation. The +/// eventual account event still requires quorum receipts and re-verifies guardian validity at the +/// authenticated quorum authority time. +pub fn verify_guardian_recovery_intent_admission( + pre_state: &crate::AccountState, + body: &crate::EventBody, + observed_at: Timestamp, +) -> Result { + let verified = crate::verifier::verify_guardian_recovery_intent(pre_state, body, observed_at)?; + Ok(ProviderLogAdmission::guardian_recovery_intent( + verified.account_id(), + verified.proposal_id(), + observed_at, + )) +} + +/// One provider-wide append index and its canonical entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProviderHistoryRecord { + leaf_index: u64, + entry: ProviderLogEntryBody, +} + +impl ProviderHistoryRecord { + /// Provider-wide zero-based append index. + pub const fn leaf_index(&self) -> u64 { + self.leaf_index + } + + /// Canonical provider-log entry at this index. + pub const fn entry(&self) -> &ProviderLogEntryBody { + &self.entry + } +} + +/// Bounded account-filtered page from one provider-wide log generation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderHistoryPage { + records: Vec, + next_cursor: Option, +} + +impl ProviderHistoryPage { + /// Matching records in provider append order. + pub fn records(&self) -> &[ProviderHistoryRecord] { + &self.records + } + + /// Exclusive provider-wide cursor to supply on the next request, if more log data remains. + pub const fn next_cursor(&self) -> Option { + self.next_cursor + } +} + +/// Atomic bounded provider-wide append-only generation suitable for deterministic tests and local use. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryTransparencyLog { + provider: ProviderDescriptor, + log_id: ProviderLogId, + entries: Vec, + checkpoint_bundles: Vec, + tree: AppendOnlyMerkleLog, + latest_head: Option, +} + +impl MemoryTransparencyLog { + /// Construct an empty provider-wide generation for one exact descriptor and log ID. + pub const fn new(provider: ProviderDescriptor, log_id: ProviderLogId) -> Self { + Self { + provider, + log_id, + entries: Vec::new(), + checkpoint_bundles: Vec::new(), + tree: AppendOnlyMerkleLog::new(), + latest_head: None, + } + } + + /// Configured provider descriptor whose key authenticates every head. + pub const fn provider(&self) -> &ProviderDescriptor { + &self.provider + } + + /// Versioned provider-wide log generation. + pub const fn log_id(&self) -> ProviderLogId { + self.log_id + } + + /// Current tree size. + pub fn tree_size(&self) -> Result { + self.tree.tree_size() + } + + /// Current Merkle root, including the distinct empty-tree domain. + pub fn tree_root(&self) -> Result { + self.tree.root() + } + + /// Most recently signed head, if this generation has emitted one. + pub const fn latest_head(&self) -> Option<&SignedProviderHead> { + self.latest_head.as_ref() + } + + /// Serve the unique current retained checkpoint bundle under reverified fork semantics. + pub fn latest_checkpoint_bundle( + &self, + account_id: AccountId, + ) -> Result, IdentityError> { + crate::provider::current_checkpoint_bundle( + &self.entries, + &self.checkpoint_bundles, + account_id, + ) + } + + /// Atomically append one already-verified admission and return exact inclusion evidence. + /// + /// Repeating an admitted `(account, subject)` does not append another leaf. It returns a fresh + /// receipt under the current tree head, preserving idempotency across publication retries. + pub fn append( + &mut self, + admission: ProviderLogAdmission, + observed_at: Timestamp, + signer: &S, + ) -> Result { + admission.validate_observed_at(observed_at)?; + let checkpoint_bundle = admission.checkpoint_bundle().cloned(); + if let Some(index) = self.entries.iter().position(|entry| { + entry.account_id() == admission.account_id() && entry.subject() == admission.subject() + }) { + self.validate_duplicate_bundle(index, checkpoint_bundle.as_ref())?; + let index = u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider log duplicate index", + })?; + return self.observe(index, observed_at, signer); + } + if self.entries.len() == MAX_MERKLE_LOG_LEAVES { + return Err(IdentityError::limit( + "provider log entries", + self.entries.len().saturating_add(1), + MAX_MERKLE_LOG_LEAVES, + )); + } + self.validate_observation_time(observed_at)?; + if let Some(bundle) = checkpoint_bundle.as_ref() { + self.validate_checkpoint_bundle(bundle)?; + } + let entry = ProviderLogEntryBody::new( + self.provider.id()?, + self.log_id, + admission.account_id(), + admission.subject(), + observed_at, + Extensions::default(), + )?; + let mut staged_entries = self.entries.clone(); + staged_entries.push(entry.clone()); + let mut staged_checkpoint_bundles = self.checkpoint_bundles.clone(); + if let Some(bundle) = checkpoint_bundle { + staged_checkpoint_bundles.push(bundle); + } + crate::provider::rebuild_checkpoint_index(&staged_entries, &staged_checkpoint_bundles)?; + let mut staged_tree = self.tree.clone(); + let leaf_index = staged_tree.append(entry.merkle_leaf_hash()?)?; + let signed_head = self.sign_head(&staged_tree, observed_at, signer)?; + let receipt = InclusionReceipt::new( + entry.clone(), + leaf_index, + staged_tree + .inclusion_proof(leaf_index)? + .audit_path() + .to_vec(), + signed_head.clone(), + )?; + receipt.verify(&self.provider)?; + + self.entries = staged_entries; + self.checkpoint_bundles = staged_checkpoint_bundles; + self.tree = staged_tree; + self.latest_head = Some(signed_head); + Ok(receipt) + } + + /// Issue a later authenticated receipt for an existing leaf without appending. + pub fn observe( + &mut self, + leaf_index: u64, + observed_at: Timestamp, + signer: &S, + ) -> Result { + self.validate_observation_time(observed_at)?; + let index = usize::try_from(leaf_index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider log observation index", + })?; + let entry = self + .entries + .get(index) + .cloned() + .ok_or(IdentityError::InvalidProof)?; + let signed_head = self.sign_head(&self.tree, observed_at, signer)?; + let proof = self.tree.inclusion_proof(leaf_index)?; + let receipt = InclusionReceipt::new( + entry, + leaf_index, + proof.audit_path().to_vec(), + signed_head.clone(), + )?; + receipt.verify(&self.provider)?; + self.latest_head = Some(signed_head); + Ok(receipt) + } + + /// Serve an exact append-only proof from an earlier prefix to the current tree. + pub fn consistency_proof( + &self, + old_size: u64, + ) -> Result { + self.tree.consistency_proof(old_size) + } + + /// Return a bounded account-filtered page while retaining a provider-wide resume cursor. + pub fn account_history( + &self, + account_id: AccountId, + after_cursor: Option, + maximum_records: usize, + maximum_bytes: usize, + ) -> Result { + if maximum_records == 0 || maximum_records > MAX_HISTORY_PAGE_EVENTS { + return Err(IdentityError::limit( + "provider account-history records", + maximum_records, + MAX_HISTORY_PAGE_EVENTS, + )); + } + if maximum_bytes == 0 || maximum_bytes > MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES { + return Err(IdentityError::limit( + "provider account-history bytes", + maximum_bytes, + MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES, + )); + } + let start = match after_cursor { + None => 0_usize, + Some(cursor) => usize::try_from(cursor) + .map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider account-history cursor", + })? + .checked_add(1) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "provider account-history cursor", + })?, + }; + if start > self.entries.len() { + return Err(IdentityError::InvalidRelationship { + resource: "provider account-history cursor", + }); + } + + let mut records = Vec::new(); + let mut cursor = after_cursor; + let mut exhausted = true; + for (index, entry) in self.entries.iter().enumerate().skip(start) { + if entry.account_id() == account_id { + if records.len() == maximum_records { + exhausted = false; + break; + } + let leaf_index = + u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider account-history leaf index", + })?; + records.push(ProviderHistoryRecord { + leaf_index, + entry: entry.clone(), + }); + // Include the leaf index, vector framing, and the larger `Some(cursor)` + // continuation shape in the exact page-size decision. + let encoded = crate::codec::encode_wire(&(records.as_slice(), Some(leaf_index)))?; + if encoded.len() > maximum_bytes { + records.pop(); + if records.is_empty() { + return Err(IdentityError::limit( + "provider account-history bytes", + encoded.len(), + maximum_bytes, + )); + } + exhausted = false; + break; + } + } + cursor = Some( + u64::try_from(index).map_err(|_| IdentityError::ArithmeticOverflow { + resource: "provider account-history cursor", + })?, + ); + } + let next_cursor = if exhausted { None } else { cursor }; + Ok(ProviderHistoryPage { + records, + next_cursor, + }) + } + + fn sign_head( + &self, + tree: &AppendOnlyMerkleLog, + observed_at: Timestamp, + signer: &S, + ) -> Result { + let body = ProviderHeadBody::new( + self.provider.id()?, + self.log_id, + ProviderKeyVersion::GENESIS, + tree.tree_size()?, + tree.root()?, + observed_at, + Extensions::default(), + )?; + let signature = signer.sign_provider_head(&body.signing_bytes()?)?; + let signed = SignedProviderHead::new(body, signature); + signed.verify(&self.provider)?; + Ok(signed) + } + + fn validate_observation_time(&self, observed_at: Timestamp) -> Result<(), IdentityError> { + if self + .latest_head + .as_ref() + .is_some_and(|head| observed_at < head.body().observed_at()) + { + return Err(IdentityError::ProviderRollback); + } + Ok(()) + } + + fn validate_checkpoint_bundle( + &self, + bundle: &crate::ProviderCheckpointBundle, + ) -> Result<(), IdentityError> { + let checkpoint = bundle.verified_checkpoint(); + let body = checkpoint.checkpoint().body(); + if let Some(prior_checkpoint_id) = bundle.prior_checkpoint_id() { + let prior_retained = self.checkpoint_bundles.iter().any(|candidate| { + let candidate_checkpoint = candidate.verified_checkpoint(); + candidate_checkpoint.checkpoint().body().account_id() == body.account_id() + && candidate_checkpoint.checkpoint_id() == prior_checkpoint_id + }); + if !prior_retained { + return Err(IdentityError::InvalidProof); + } + } + + let mut greatest_sequence: Option = None; + let mut greatest_epoch: Option = None; + for retained in &self.checkpoint_bundles { + let retained_body = retained.verified_checkpoint().checkpoint().body(); + if retained_body.account_id() != body.account_id() { + continue; + } + greatest_sequence = Some( + greatest_sequence.map_or(retained_body.sequence(), |sequence| { + sequence.max(retained_body.sequence()) + }), + ); + greatest_epoch = Some( + greatest_epoch.map_or(retained_body.account_epoch(), |epoch| { + epoch.max(retained_body.account_epoch()) + }), + ); + } + if greatest_sequence.is_some_and(|sequence| body.sequence() < sequence) + || greatest_epoch.is_some_and(|epoch| body.account_epoch() < epoch) + { + return Err(IdentityError::ProviderRollback); + } + Ok(()) + } + + fn validate_duplicate_bundle( + &self, + entry_index: usize, + candidate: Option<&crate::ProviderCheckpointBundle>, + ) -> Result<(), IdentityError> { + let entry = self + .entries + .get(entry_index) + .ok_or(IdentityError::StorageCorruption)?; + match (entry.subject(), candidate) { + (ProviderLogSubject::Checkpoint(checkpoint_id), Some(candidate)) => { + let retained = self + .checkpoint_bundles + .iter() + .find(|bundle| { + let checkpoint = bundle.verified_checkpoint(); + checkpoint.checkpoint().body().account_id() == entry.account_id() + && checkpoint.checkpoint_id() == checkpoint_id + }) + .ok_or(IdentityError::StorageCorruption)?; + if retained != candidate { + return Err(IdentityError::InvalidProof); + } + Ok(()) + } + (ProviderLogSubject::Checkpoint(_), None) + | (ProviderLogSubject::EventIntent(_), Some(_)) => { + Err(IdentityError::InvalidRelationship { + resource: "provider duplicate admission material", + }) + } + (ProviderLogSubject::EventIntent(_), None) => Ok(()), + } + } +} + +#[cfg(test)] +mod tests { + use krikos_base::SecretKey; + + use super::*; + use crate::{ + CanonicalWire, Digest, HashAlgorithm, ProviderId, SigningPublicKey, + verify_provider_head_progression, + }; + + struct Signer(SecretKey); + + impl ProviderHeadSigner for Signer { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } + } + + fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() + } + + fn fixture() -> (MemoryTransparencyLog, Signer) { + let signer = Signer(SecretKey::from_bytes(&[0x61; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + ( + MemoryTransparencyLog::new(provider, typed_id::(0x62)), + signer, + ) + } + + #[test] + fn append_observe_and_consistency_are_atomic_and_authenticated() { + let (mut log, signer) = fixture(); + let first = ProviderLogAdmission::event_intent( + typed_id::(0x63), + typed_id::(0x64), + ); + let first_receipt = log + .append(first.clone(), Timestamp::from_unix_millis(10), &signer) + .unwrap(); + let first_head = first_receipt.signed_head().clone(); + let second = ProviderLogAdmission::event_intent( + typed_id::(0x65), + typed_id::(0x66), + ); + log.append(second, Timestamp::from_unix_millis(11), &signer) + .unwrap(); + let observed = log + .observe(0, Timestamp::from_unix_millis(12), &signer) + .unwrap(); + verify_provider_head_progression( + log.provider(), + &first_head, + observed.signed_head(), + &log.consistency_proof(1).unwrap(), + ) + .unwrap(); + assert_eq!(log.tree_size().unwrap(), 2); + assert_eq!(observed.entry(), first_receipt.entry()); + + let replay = log + .append(first, Timestamp::from_unix_millis(13), &signer) + .unwrap(); + assert_eq!(log.tree_size().unwrap(), 2); + assert_eq!(replay.leaf_index(), 0); + let before = log.clone(); + assert_eq!( + log.observe(0, Timestamp::from_unix_millis(9), &signer), + Err(IdentityError::ProviderRollback) + ); + assert_eq!(log, before); + } + + #[test] + fn invalid_signer_and_bounded_history_fail_without_partial_append() { + let (mut log, signer) = fixture(); + let account = typed_id::(0x67); + let admission = + ProviderLogAdmission::event_intent(account, typed_id::(0x68)); + let wrong = Signer(SecretKey::from_bytes(&[0x69; 32])); + let before = log.clone(); + assert_eq!( + log.append(admission.clone(), Timestamp::from_unix_millis(10), &wrong), + Err(IdentityError::InvalidSignature) + ); + assert_eq!(log, before); + + log.append(admission.clone(), Timestamp::from_unix_millis(10), &signer) + .unwrap(); + log.append( + ProviderLogAdmission::event_intent(account, typed_id::(0x6a)), + Timestamp::from_unix_millis(11), + &signer, + ) + .unwrap(); + let page = log + .account_history(account, None, 1, MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES) + .unwrap(); + assert_eq!(page.records().len(), 1); + assert!(page.next_cursor().is_some()); + let second_page = log + .account_history( + account, + page.next_cursor(), + 1, + MAX_PROVIDER_ACCOUNT_RESPONSE_BYTES, + ) + .unwrap(); + assert_eq!(second_page.records().len(), 1); + assert_eq!(second_page.next_cursor(), None); + let _: ProviderId = log.provider().id().unwrap(); + } +} diff --git a/protocols/krikos-identity/src/transport.rs b/protocols/krikos-identity/src/transport.rs new file mode 100644 index 00000000000..250beb279cf --- /dev/null +++ b/protocols/krikos-identity/src/transport.rs @@ -0,0 +1,158 @@ +//! Runtime-independent identity transport contracts. + +use crate::{ + AccountId, CheckpointId, DeviceId, EndpointPublicKey, IdentityError, ProjectedDeviceLifecycle, + StoreFuture, +}; + +/// Pairing protocol v1 ALPN. +pub const PAIRING_ALPN: &[u8] = b"krikos-identity/pairing/1"; +/// Account synchronization protocol v1 ALPN. +pub const SYNC_ALPN: &[u8] = b"krikos-identity/sync/1"; +/// Authorization-proposal protocol v1 ALPN. +pub const PROPOSAL_ALPN: &[u8] = b"krikos-identity/proposal/1"; +/// Account-checkpoint protocol v1 ALPN. +pub const CHECKPOINT_ALPN: &[u8] = b"krikos-identity/checkpoint/1"; +/// Transparency-gossip protocol v1 ALPN. +pub const TRANSPARENCY_GOSSIP_ALPN: &[u8] = b"krikos-identity/transparency-gossip/1"; +/// Recovery protocol v1 ALPN. +pub const RECOVERY_ALPN: &[u8] = b"krikos-identity/recovery/1"; + +/// Device endpoint record resolved only after checkpoint verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CheckpointDeviceEndpoint { + endpoint_key: EndpointPublicKey, + lifecycle: ProjectedDeviceLifecycle, +} + +impl CheckpointDeviceEndpoint { + /// Construct a record at a verified-checkpoint implementation boundary. + pub fn new(endpoint_key: EndpointPublicKey, lifecycle: ProjectedDeviceLifecycle) -> Self { + Self { + endpoint_key, + lifecycle, + } + } + + /// Endpoint key committed by the verified checkpoint projection. + pub const fn endpoint_key(self) -> EndpointPublicKey { + self.endpoint_key + } + + /// Device lifecycle committed by the verified checkpoint projection. + pub const fn lifecycle(self) -> ProjectedDeviceLifecycle { + self.lifecycle + } +} + +/// Trusted lookup boundary for an already cryptographically verified checkpoint. +pub trait VerifiedCheckpointView: Send + Sync { + /// Resolve one device from the exact verified checkpoint, or `None` when absent. + fn device_endpoint( + &self, + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + ) -> Result, IdentityError>; +} + +/// Capability proving an authenticated stream endpoint is active at one verified checkpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthorizedEndpointStream { + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + endpoint_key: EndpointPublicKey, +} + +impl AuthorizedEndpointStream { + /// Authorized account. + pub const fn account_id(self) -> AccountId { + self.account_id + } + + /// Verified checkpoint used for authorization. + pub const fn checkpoint_id(self) -> CheckpointId { + self.checkpoint_id + } + + /// Active device bound to the endpoint. + pub const fn device_id(self) -> DeviceId { + self.device_id + } + + /// Exact authenticated remote endpoint key. + pub const fn endpoint_key(self) -> EndpointPublicKey { + self.endpoint_key + } +} + +/// Authorize an authenticated remote endpoint before any protocol dispatch. +pub fn authorize_endpoint_stream( + view: &(impl VerifiedCheckpointView + ?Sized), + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + remote_endpoint_key: EndpointPublicKey, +) -> Result { + let record = view + .device_endpoint(account_id, checkpoint_id, device_id)? + .ok_or(IdentityError::DeviceNotAuthorized)?; + match record.lifecycle() { + ProjectedDeviceLifecycle::Active => {} + ProjectedDeviceLifecycle::Suspended => return Err(IdentityError::DeviceSuspended), + ProjectedDeviceLifecycle::Revoked => return Err(IdentityError::DeviceRevoked), + } + if record.endpoint_key() != remote_endpoint_key { + return Err(IdentityError::DeviceNotAuthorized); + } + Ok(AuthorizedEndpointStream { + account_id, + checkpoint_id, + device_id, + endpoint_key: remote_endpoint_key, + }) +} + +/// Runtime-independent bidirectional length-delimited stream. +pub trait IdentityStream: Send { + /// Send one already bounded canonical frame. + fn send_frame(&mut self, frame: Vec) -> StoreFuture<'_, ()>; + + /// Receive one frame after length validation and before protocol dispatch. + fn receive_frame(&mut self) -> StoreFuture<'_, Option>>; +} + +/// Authenticated endpoint transport capable of opening one exact ALPN stream. +pub trait IdentityTransport: Send + Sync { + /// Concrete owned stream. + type Stream: IdentityStream; + + /// Open a stream to an authenticated endpoint under an exact supported ALPN. + fn open_stream( + &self, + endpoint_key: EndpointPublicKey, + alpn: &'static [u8], + ) -> StoreFuture<'_, Self::Stream>; +} + +/// Explicit endpoint discovery boundary. +pub trait IdentityDiscovery: Send + Sync { + /// Resolve bounded opaque endpoint-address records for one endpoint key. + fn resolve_endpoint(&self, endpoint_key: EndpointPublicKey) -> StoreFuture<'_, Vec>>; +} + +/// Explicit bounded transparency-gossip boundary. +pub trait IdentityGossip: Send + Sync { + /// Publish one bounded canonical gossip record. + fn publish(&self, topic: Vec, record: Vec) -> StoreFuture<'_, ()>; +} + +/// Explicit content-addressed blob boundary used by identity integrations. +pub trait IdentityBlobStore: Send + Sync { + /// Store one bounded blob and return its exact content digest. + fn put(&self, bytes: Vec) -> StoreFuture<'_, [u8; 32]>; + + /// Load a blob, distinguishing absence from transport or storage failure. + fn get(&self, digest: [u8; 32]) -> StoreFuture<'_, Option>>; +} diff --git a/protocols/krikos-identity/src/types.rs b/protocols/krikos-identity/src/types.rs new file mode 100644 index 00000000000..5369506a06b --- /dev/null +++ b/protocols/krikos-identity/src/types.rs @@ -0,0 +1,1059 @@ +//! Foundational protocol types and cryptographic algorithm registry. + +use std::fmt; + +use curve25519_dalek::montgomery::MontgomeryPoint; +use data_encoding::HEXLOWER; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + AlgorithmKind, IdentityError, + codec::{decode_wire, encode_wire, sealed::CanonicalCodec}, +}; + +macro_rules! algorithm { + ($name:ident, $kind:expr, $variant:ident, $code:expr, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[non_exhaustive] + pub enum $name { + #[doc = concat!("The v1 ", $doc, ".")] + $variant, + } + + impl $name { + /// Stable v1 wire codepoint. + pub const fn code(self) -> u16 { + match self { + Self::$variant => $code, + } + } + + pub(crate) const fn from_code(code: u16) -> Result { + match code { + $code => Ok(Self::$variant), + other => Err(IdentityError::unsupported_algorithm($kind, other)), + } + } + } + + impl CanonicalCodec for $name { + const RESOURCE: &'static str = stringify!($name); + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&self.code()) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + Self::from_code(decode_wire(bytes)?) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_code(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } + } + }; +} + +algorithm!( + HashAlgorithm, + AlgorithmKind::Hash, + Blake3_256, + 1, + "BLAKE3-256 hash algorithm" +); +algorithm!( + SignatureAlgorithm, + AlgorithmKind::Signature, + Ed25519, + 1, + "Ed25519 signature algorithm" +); +algorithm!( + AgreementAlgorithm, + AlgorithmKind::Agreement, + X25519, + 1, + "X25519 key-agreement algorithm" +); +algorithm!( + KdfAlgorithm, + AlgorithmKind::Kdf, + Blake3DeriveKey, + 1, + "BLAKE3 derive-key algorithm" +); +algorithm!( + AeadAlgorithm, + AlgorithmKind::Aead, + XChaCha20Poly1305, + 1, + "XChaCha20-Poly1305 authenticated-encryption algorithm" +); + +/// Reserved v1 codepoint for the design's checkpoint-publication record. +/// +/// Publication is an availability-plane journal record rather than an authoritative +/// account operation, so this codepoint is never accepted by [`OperationKind`]. +pub const RESERVED_PUBLISH_CHECKPOINT_CODE: u16 = 23; + +/// Closed registry of authoritative v1 account operation kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum OperationKind { + /// Authorize a new independently identified device. + AuthorizeDevice, + /// Change security-relevant device authorization or capabilities. + UpdateDeviceAuthorization, + /// Change only an opaque device metadata commitment. + UpdateDeviceMetadata, + /// Temporarily suspend a device. + SuspendDevice, + /// Reinstate a suspended device. + ReinstateDevice, + /// Terminally revoke a device identifier. + RevokeDevice, + /// Replace a device identifier with independently generated keys. + RotateDeviceKeys, + /// Add an account controller. + AddController, + /// Terminally remove an account controller identifier. + RemoveController, + /// Change the account-control policy under the previous policy. + ChangeControlPolicy, + /// Change the explicit recovery policy. + ChangeRecoveryPolicy, + /// Change the account's minimum transparency-provider policy. + ChangeProviderPolicy, + /// Begin a durable delayed recovery attempt. + BeginRecovery, + /// Veto a pending recovery under its pre-existing veto policy. + VetoRecovery, + /// Cancel a pending recovery. + CancelRecovery, + /// Finalize a sufficiently authorized and delayed recovery. + FinalizeRecovery, + /// Resolve a complete bounded fork under the common pre-fork policy. + ResolveFork, + /// Begin a cross-signed cryptographic-suite migration. + BeginCryptoMigration, + /// Activate the overlapping cryptographic suite. + ActivateCryptoMigration, + /// Retire the old suite after the overlap period. + RetireCryptoSuite, + /// Upgrade the account protocol major version. + UpgradeProtocol, + /// Terminally retire the account. + RetireAccount, +} + +impl OperationKind { + /// Stable v1 operation codepoint. + pub const fn code(self) -> u16 { + match self { + Self::AuthorizeDevice => 1, + Self::UpdateDeviceAuthorization => 2, + Self::UpdateDeviceMetadata => 3, + Self::SuspendDevice => 4, + Self::ReinstateDevice => 5, + Self::RevokeDevice => 6, + Self::RotateDeviceKeys => 7, + Self::AddController => 8, + Self::RemoveController => 9, + Self::ChangeControlPolicy => 10, + Self::ChangeRecoveryPolicy => 11, + Self::ChangeProviderPolicy => 12, + Self::BeginRecovery => 13, + Self::VetoRecovery => 14, + Self::CancelRecovery => 15, + Self::FinalizeRecovery => 16, + Self::ResolveFork => 17, + Self::BeginCryptoMigration => 18, + Self::ActivateCryptoMigration => 19, + Self::RetireCryptoSuite => 20, + Self::UpgradeProtocol => 21, + Self::RetireAccount => 22, + } + } + + /// Decode one closed v1 operation codepoint. + pub const fn from_code(code: u16) -> Result { + match code { + 1 => Ok(Self::AuthorizeDevice), + 2 => Ok(Self::UpdateDeviceAuthorization), + 3 => Ok(Self::UpdateDeviceMetadata), + 4 => Ok(Self::SuspendDevice), + 5 => Ok(Self::ReinstateDevice), + 6 => Ok(Self::RevokeDevice), + 7 => Ok(Self::RotateDeviceKeys), + 8 => Ok(Self::AddController), + 9 => Ok(Self::RemoveController), + 10 => Ok(Self::ChangeControlPolicy), + 11 => Ok(Self::ChangeRecoveryPolicy), + 12 => Ok(Self::ChangeProviderPolicy), + 13 => Ok(Self::BeginRecovery), + 14 => Ok(Self::VetoRecovery), + 15 => Ok(Self::CancelRecovery), + 16 => Ok(Self::FinalizeRecovery), + 17 => Ok(Self::ResolveFork), + 18 => Ok(Self::BeginCryptoMigration), + 19 => Ok(Self::ActivateCryptoMigration), + 20 => Ok(Self::RetireCryptoSuite), + 21 => Ok(Self::UpgradeProtocol), + 22 => Ok(Self::RetireAccount), + RESERVED_PUBLISH_CHECKPOINT_CODE => Err(IdentityError::ReservedCodepoint { + registry: "account operation", + code, + }), + unsupported => Err(IdentityError::UnsupportedCodepoint { + registry: "account operation", + code: unsupported, + }), + } + } +} + +impl CanonicalCodec for OperationKind { + const RESOURCE: &'static str = "account operation kind bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&self.code()) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + Self::from_code(decode_wire(bytes)?) + } +} + +impl Serialize for OperationKind { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.code().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for OperationKind { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_code(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// A 256-bit algorithm-tagged digest. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Digest { + algorithm: HashAlgorithm, + bytes: [u8; 32], +} + +impl Digest { + /// Construct a digest from its algorithm and exact bytes. + pub const fn new(algorithm: HashAlgorithm, bytes: [u8; 32]) -> Self { + Self { algorithm, bytes } + } + + /// Hash algorithm used to create this digest. + pub const fn algorithm(self) -> HashAlgorithm { + self.algorithm + } + + /// Digest bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.bytes + } +} + +impl fmt::Debug for Digest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("Digest") + .field(&self.to_string()) + .finish() + } +} + +impl fmt::Display for Digest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let prefix = match self.algorithm { + HashAlgorithm::Blake3_256 => "b3", + }; + write!(formatter, "{prefix}:{}", HEXLOWER.encode(&self.bytes)) + } +} + +impl CanonicalCodec for Digest { + const RESOURCE: &'static str = "digest bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&(self.algorithm.code(), self.bytes)) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let (algorithm, digest): (u16, [u8; 32]) = decode_wire(bytes)?; + Ok(Self::new(HashAlgorithm::from_code(algorithm)?, digest)) + } +} + +impl Serialize for Digest { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.algorithm.code(), self.bytes).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Digest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (algorithm, bytes) = <(u16, [u8; 32])>::deserialize(deserializer)?; + let algorithm = HashAlgorithm::from_code(algorithm).map_err(de::Error::custom)?; + Ok(Self::new(algorithm, bytes)) + } +} + +/// Domain separators for protocol-owned v1 identity hashes. +#[allow(dead_code)] // Variants are consumed incrementally by the complete Task 2 schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub(crate) enum HashDomain { + /// Stable foundation conformance vector. + TestVector, + /// Canonical genesis record. + AccountGenesis, + /// Domain-separated predecessor of an account's first event. + GenesisAnchor, + /// Stable account identifier. + AccountId, + /// Controller descriptor identifier. + ControllerDescriptor, + /// Controller signing-key binding identifier. + ControllerKey, + /// Control-policy identifier. + ControlPolicy, + /// Recovery-policy identifier. + RecoveryPolicy, + /// Transparency-provider descriptor identifier. + Provider, + /// Transparency-provider log identifier. + ProviderLog, + /// Account provider-policy identifier. + ProviderPolicy, + /// Device public descriptor identifier. + DeviceDescriptor, + /// Device-bound agreement-key identifier used by recipient wraps. + AgreementKey, + /// Unsigned account event proposal. + AccountProposal, + /// Authorized account event. + AccountEvent, + /// Exact complete authorization envelope for an account event. + EventAuthorization, + /// Account checkpoint. + AccountCheckpoint, + /// Signed proposal intent from one controller. + EventIntentApproval, + /// Final controller approval body. + ControllerApproval, + /// Historical admission evidence. + AdmissionEvidence, + /// Capability grant identifier. + CapabilityGrant, + /// Capability delegation identifier. + CapabilityDelegation, + /// Complete account-state root. + StateRoot, + /// Authorized-device set root. + AuthorizedSet, + /// Revoked-device set root. + RevokedSet, + /// Pairing ticket commitment. + PairingTicket, + /// Complete pairing transcript. + PairingTranscript, + /// Device presence proof. + PresenceProof, + /// Signed application event. + ApplicationEvent, + /// Wrapped application group key. + GroupKeyWrap, + /// Transparency Merkle leaf. + MerkleLeaf, + /// Transparency Merkle interior node. + MerkleNode, + /// Empty transparency Merkle tree. + MerkleEmpty, + /// Transparency provider signed head. + ProviderHead, + /// Transparency provider log entry. + ProviderLogEntry, + /// Recovery proposal identifier. + Recovery, + /// Recovery guardian grant identifier. + GuardianGrant, + /// Complete fork descriptor identifier. + Fork, + /// Cryptographic suite descriptor identifier. + CryptoSuite, + /// Cryptographic migration identifier. + CryptoMigration, + /// Controller old/new key cross-binding. + CryptoKeyBinding, + /// Projected cryptographic state identifier. + CryptoState, + /// Application namespace identifier. + ApplicationId, + /// Application group identifier. + GroupId, + /// Private social attestation. + SocialAttestation, + /// Pairwise account pseudonym. + PairwiseId, + /// Optional public-ledger anchor commitment. + AnchorCommitment, +} + +impl HashDomain { + pub(crate) const fn prefix(self) -> &'static [u8] { + match self { + Self::TestVector => b"KRIKOS-ID/test/v1", + Self::AccountGenesis => b"KRIKOS-ID/account-genesis/v1", + Self::GenesisAnchor => b"KRIKOS-ID/genesis-anchor/v1", + Self::AccountId => b"KRIKOS-ID/account-id/v1", + Self::ControllerDescriptor => b"KRIKOS-ID/controller/v1", + Self::ControllerKey => b"KRIKOS-ID/controller-key/v1", + Self::ControlPolicy => b"KRIKOS-ID/control-policy/v1", + Self::RecoveryPolicy => b"KRIKOS-ID/recovery-policy/v1", + Self::Provider => b"KRIKOS-ID/provider/v1", + Self::ProviderLog => b"KRIKOS-ID/provider-log/v1", + Self::ProviderPolicy => b"KRIKOS-ID/provider-policy/v1", + Self::DeviceDescriptor => b"KRIKOS-ID/device/v1", + Self::AgreementKey => b"KRIKOS-ID/agreement-key/v1", + Self::AccountProposal => b"KRIKOS-ID/account-proposal/v1", + Self::AccountEvent => b"KRIKOS-ID/account-event/v1", + Self::EventAuthorization => b"KRIKOS-ID/event-authorization/v1", + Self::AccountCheckpoint => b"KRIKOS-ID/account-checkpoint/v1", + Self::EventIntentApproval => b"KRIKOS-ID/event-intent-approval/v1", + Self::ControllerApproval => b"KRIKOS-ID/controller-approval/v1", + Self::AdmissionEvidence => b"KRIKOS-ID/admission-evidence/v1", + Self::CapabilityGrant => b"KRIKOS-ID/capability-grant/v1", + Self::CapabilityDelegation => b"KRIKOS-ID/capability-delegation/v1", + Self::StateRoot => b"KRIKOS-ID/state-root/v1", + Self::AuthorizedSet => b"KRIKOS-ID/authorized-set/v1", + Self::RevokedSet => b"KRIKOS-ID/revoked-set/v1", + Self::PairingTicket => b"KRIKOS-ID/pairing-ticket/v1", + Self::PairingTranscript => b"KRIKOS-ID/pairing-transcript/v1", + Self::PresenceProof => b"KRIKOS-ID/presence-proof/v1", + Self::ApplicationEvent => b"KRIKOS-ID/application-event/v1", + Self::GroupKeyWrap => b"KRIKOS-ID/group-key-wrap/v1", + Self::MerkleLeaf => b"KRIKOS-ID/merkle-leaf/v1", + Self::MerkleNode => b"KRIKOS-ID/merkle-node/v1", + Self::MerkleEmpty => b"KRIKOS-ID/merkle-empty/v1", + Self::ProviderHead => b"KRIKOS-ID/provider-head/v1", + Self::ProviderLogEntry => b"KRIKOS-ID/provider-log-entry/v1", + Self::Recovery => b"KRIKOS-ID/recovery/v1", + Self::GuardianGrant => b"KRIKOS-ID/guardian-grant/v1", + Self::Fork => b"KRIKOS-ID/fork/v1", + Self::CryptoSuite => b"KRIKOS-ID/crypto-suite/v1", + Self::CryptoMigration => b"KRIKOS-ID/crypto-migration/v1", + Self::CryptoKeyBinding => b"KRIKOS-ID/crypto-key-binding/v1", + Self::CryptoState => b"KRIKOS-ID/crypto-state/v1", + Self::ApplicationId => b"KRIKOS-ID/application-id/v1", + Self::GroupId => b"KRIKOS-ID/group-id/v1", + Self::SocialAttestation => b"KRIKOS-ID/social-attestation/v1", + Self::PairwiseId => b"KRIKOS-ID/pairwise-id/v1", + Self::AnchorCommitment => b"KRIKOS-ID/anchor/v1", + } + } +} + +/// Hash bytes using the v1 algorithm and a mandatory protocol domain separator. +#[allow(dead_code)] // Typed schema derivations are introduced incrementally in Task 2. +pub(crate) fn hash_bytes(domain: HashDomain, payload: &[u8]) -> Digest { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain.prefix()); + hasher.update(&[0]); + hasher.update(payload); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::{HashDomain, hash_bytes}; + + #[test] + fn domain_separated_hash_vector_is_frozen() { + let digest = hash_bytes(HashDomain::TestVector, b"abc"); + assert_eq!( + digest.to_string(), + "b3:5d2f1aacba9c5e36c83962fd211e1382725e0bd71e4601019730afd05ea06b53" + ); + } +} + +/// Algorithm-tagged Ed25519 public signing key. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SigningPublicKey { + algorithm: SignatureAlgorithm, + bytes: [u8; 32], +} + +impl SigningPublicKey { + /// Validate and construct an Ed25519 public key. + pub fn ed25519(bytes: [u8; 32]) -> Result { + let key = krikos_base::PublicKey::from_bytes(&bytes).map_err(|_| { + IdentityError::InvalidPublicKey { + kind: AlgorithmKind::Signature, + } + })?; + if key.as_verifying_key().is_weak() { + return Err(IdentityError::InvalidPublicKey { + kind: AlgorithmKind::Signature, + }); + } + Ok(Self { + algorithm: SignatureAlgorithm::Ed25519, + bytes, + }) + } + + /// Signature algorithm for this key. + pub const fn algorithm(self) -> SignatureAlgorithm { + self.algorithm + } + + /// Exact public-key bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.bytes + } +} + +impl fmt::Debug for SigningPublicKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SigningPublicKey") + .field("algorithm", &self.algorithm) + .field("key", &HEXLOWER.encode(&self.bytes)) + .finish() + } +} + +impl CanonicalCodec for SigningPublicKey { + const RESOURCE: &'static str = "signing public key bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&(self.algorithm.code(), self.bytes)) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let (algorithm, key): (u16, [u8; 32]) = decode_wire(bytes)?; + match SignatureAlgorithm::from_code(algorithm)? { + SignatureAlgorithm::Ed25519 => Self::ed25519(key), + } + } +} + +impl Serialize for SigningPublicKey { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.algorithm.code(), self.bytes).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SigningPublicKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (algorithm, bytes) = <(u16, [u8; 32])>::deserialize(deserializer)?; + match SignatureAlgorithm::from_code(algorithm).map_err(de::Error::custom)? { + SignatureAlgorithm::Ed25519 => Self::ed25519(bytes).map_err(de::Error::custom), + } + } +} + +/// Algorithm-tagged X25519 public agreement key. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AgreementPublicKey { + algorithm: AgreementAlgorithm, + bytes: [u8; 32], +} + +impl AgreementPublicKey { + /// Validate and construct an X25519 public key. + pub fn x25519(bytes: [u8; 32]) -> Result { + if !x25519_public_key_is_valid(&bytes) { + return Err(IdentityError::InvalidPublicKey { + kind: AlgorithmKind::Agreement, + }); + } + Ok(Self { + algorithm: AgreementAlgorithm::X25519, + bytes, + }) + } + + /// Agreement algorithm for this key. + pub const fn algorithm(self) -> AgreementAlgorithm { + self.algorithm + } + + /// Exact public-key bytes. + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.bytes + } +} + +fn x25519_public_key_is_valid(bytes: &[u8; 32]) -> bool { + // RFC 7748 decoders mask the high bit and reduce non-canonical field + // encodings. Device identity hashes the encoded key, so accepting those + // aliases would give one agreement key multiple DeviceIds. + const FIELD_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ]; + if !little_endian_less_than(bytes, &FIELD_MODULUS) { + return false; + } + + // A clamped scalar is a multiple of the cofactor, so every low-order input + // produces the all-zero X25519 result. The fixed scalar is only a validation + // probe; no secret material is involved. + MontgomeryPoint(*bytes).mul_clamped([0x42; 32]).0 != [0; 32] +} + +fn little_endian_less_than(left: &[u8; 32], right: &[u8; 32]) -> bool { + for index in (0..left.len()).rev() { + if left[index] < right[index] { + return true; + } + if left[index] > right[index] { + return false; + } + } + false +} + +impl fmt::Debug for AgreementPublicKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AgreementPublicKey") + .field("algorithm", &self.algorithm) + .field("key", &HEXLOWER.encode(&self.bytes)) + .finish() + } +} + +impl CanonicalCodec for AgreementPublicKey { + const RESOURCE: &'static str = "agreement public key bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&(self.algorithm.code(), self.bytes)) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let (algorithm, key): (u16, [u8; 32]) = decode_wire(bytes)?; + match AgreementAlgorithm::from_code(algorithm)? { + AgreementAlgorithm::X25519 => Self::x25519(key), + } + } +} + +impl Serialize for AgreementPublicKey { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.algorithm.code(), self.bytes).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for AgreementPublicKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (algorithm, bytes) = <(u16, [u8; 32])>::deserialize(deserializer)?; + match AgreementAlgorithm::from_code(algorithm).map_err(de::Error::custom)? { + AgreementAlgorithm::X25519 => Self::x25519(bytes).map_err(de::Error::custom), + } + } +} + +/// Algorithm-tagged digital signature. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProtocolSignature { + algorithm: SignatureAlgorithm, + bytes: [u8; 64], +} + +impl ProtocolSignature { + /// Construct an Ed25519 signature from its exact bytes. + pub const fn ed25519(bytes: [u8; 64]) -> Self { + Self { + algorithm: SignatureAlgorithm::Ed25519, + bytes, + } + } + + /// Signature algorithm. + pub const fn algorithm(self) -> SignatureAlgorithm { + self.algorithm + } + + /// Exact signature bytes. + pub const fn as_bytes(&self) -> &[u8; 64] { + &self.bytes + } +} + +impl fmt::Debug for ProtocolSignature { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtocolSignature") + .field("algorithm", &self.algorithm) + .field("signature", &"") + .finish() + } +} + +impl CanonicalCodec for ProtocolSignature { + const RESOURCE: &'static str = "signature bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&(self.algorithm.code(), SignatureBytes(self.bytes))) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + let (algorithm, signature): (u16, SignatureBytes) = decode_wire(bytes)?; + match SignatureAlgorithm::from_code(algorithm)? { + SignatureAlgorithm::Ed25519 => Ok(Self::ed25519(signature.0)), + } + } +} + +impl Serialize for ProtocolSignature { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + (self.algorithm.code(), SignatureBytes(self.bytes)).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProtocolSignature { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let (algorithm, signature) = <(u16, SignatureBytes)>::deserialize(deserializer)?; + match SignatureAlgorithm::from_code(algorithm).map_err(de::Error::custom)? { + SignatureAlgorithm::Ed25519 => Ok(Self::ed25519(signature.0)), + } + } +} + +#[derive(Serialize, Deserialize)] +struct SignatureBytes(#[serde(with = "signature_bytes")] [u8; 64]); + +mod signature_bytes { + use std::fmt; + + use serde::{Deserializer, Serializer, de, ser::SerializeTuple}; + + pub(super) fn serialize(bytes: &[u8; 64], serializer: S) -> Result + where + S: Serializer, + { + let mut tuple = serializer.serialize_tuple(bytes.len())?; + for byte in bytes { + tuple.serialize_element(byte)?; + } + tuple.end() + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 64], D::Error> + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = [u8; 64]; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an exact 64-byte signature") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let mut bytes = [0; 64]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = sequence + .next_element()? + .ok_or_else(|| de::Error::invalid_length(index, &self))?; + } + Ok(bytes) + } + } + + deserializer.deserialize_tuple(64, Visitor) + } +} + +macro_rules! counter { + ($name:ident, $zero:ident, $resource:literal, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(u64); + + impl $name { + /// Initial zero value. + pub const $zero: Self = Self(0); + + /// Construct from the exact wire value. + pub const fn new(value: u64) -> Self { + Self(value) + } + + /// Return the underlying value. + pub const fn get(self) -> u64 { + self.0 + } + + /// Advance by exactly one, rejecting exhaustion. + pub fn checked_next(self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or(IdentityError::ArithmeticOverflow { + resource: $resource, + }) + } + } + + impl CanonicalCodec for $name { + const RESOURCE: &'static str = $resource; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&self.0) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + Ok(Self(decode_wire(bytes)?)) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self(u64::deserialize(deserializer)?)) + } + } + }; +} + +counter!( + Epoch, + GENESIS, + "account epoch", + "Security-relevant account epoch." +); +counter!( + Sequence, + GENESIS, + "account sequence", + "Linear account-event sequence number." +); + +/// Identity protocol major version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ProtocolVersion(u16); + +impl ProtocolVersion { + /// The only protocol version supported by this implementation. + pub const V1: Self = Self(1); + + /// Validate a protocol major version. + pub const fn new(version: u16) -> Result { + match version { + 1 => Ok(Self::V1), + unsupported => Err(IdentityError::UnsupportedVersion { + version: unsupported, + }), + } + } + + /// Stable wire value. + pub const fn get(self) -> u16 { + self.0 + } +} + +impl CanonicalCodec for ProtocolVersion { + const RESOURCE: &'static str = "protocol version bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&self.0) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + Self::new(decode_wire(bytes)?) + } +} + +impl Serialize for ProtocolVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ProtocolVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::new(u16::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// Milliseconds since the Unix epoch, supplied explicitly by an effect boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Timestamp(u64); + +impl Timestamp { + /// Construct an explicit Unix timestamp. + pub const fn from_unix_millis(milliseconds: u64) -> Self { + Self(milliseconds) + } + + /// Return milliseconds since the Unix epoch. + pub const fn as_unix_millis(self) -> u64 { + self.0 + } + + /// Add a protocol duration, rejecting overflow. + pub fn checked_add(self, duration: DurationMillis) -> Result { + self.0 + .checked_add(duration.get()) + .map(Self) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "timestamp milliseconds", + }) + } +} + +impl CanonicalCodec for Timestamp { + const RESOURCE: &'static str = "timestamp bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&self.0) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + Ok(Self(decode_wire(bytes)?)) + } +} + +impl Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Timestamp { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self::from_unix_millis(u64::deserialize(deserializer)?)) + } +} + +/// Explicit duration measured in milliseconds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DurationMillis(u64); + +impl DurationMillis { + /// Construct an exact millisecond duration. + pub const fn new(milliseconds: u64) -> Self { + Self(milliseconds) + } + + /// Return the exact number of milliseconds. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl CanonicalCodec for DurationMillis { + const RESOURCE: &'static str = "duration milliseconds bytes"; + + fn encode_canonical(&self) -> Result, IdentityError> { + encode_wire(&self.0) + } + + fn decode_canonical(bytes: &[u8]) -> Result { + Ok(Self(decode_wire(bytes)?)) + } +} + +impl Serialize for DurationMillis { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for DurationMillis { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self::new(u64::deserialize(deserializer)?)) + } +} diff --git a/protocols/krikos-identity/src/verifier.rs b/protocols/krikos-identity/src/verifier.rs new file mode 100644 index 00000000000..0876455a1cb --- /dev/null +++ b/protocols/krikos-identity/src/verifier.rs @@ -0,0 +1,940 @@ +//! Pure pre-state account-event verification helpers. + +use krikos_base::{PublicKey, Signature}; + +use crate::{ + AccountState, AlgorithmKind, AlgorithmSignature, AuthorizedEvent, CanonicalWire, ControllerId, + FreshnessRequirement, IdentityError, ProviderMode, +}; + +/// Authority facts derived while validating one event envelope. +pub(crate) struct ValidatedEvent { + provider_authority_time: Option, +} + +impl ValidatedEvent { + /// Deterministic provider-quorum signed-head time, when required by the rule. + pub(crate) const fn provider_authority_time(&self) -> Option { + self.provider_authority_time + } +} + +/// Opaque result proving one exact event intent met its authenticated pre-state threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct VerifiedEventIntent { + account_id: crate::AccountId, + proposal_id: crate::ProposalId, +} + +impl VerifiedEventIntent { + /// Account whose current projected authority approved the intent. + pub(crate) const fn account_id(self) -> crate::AccountId { + self.account_id + } + + /// Exact body-only proposal approved by the projected authority. + pub(crate) const fn proposal_id(self) -> crate::ProposalId { + self.proposal_id + } +} + +/// Verify a threshold-approved proposal intent against one exact authenticated pre-state. +pub(crate) fn verify_event_intent( + pre_state: &AccountState, + body: &crate::EventBody, + approvals: &crate::EventIntentApprovals, +) -> Result { + if body.account_id() != pre_state.account_id() { + return Err(IdentityError::AccountMismatch); + } + if body.sequence() != pre_state.sequence().checked_next()? { + return Err(IdentityError::InvalidSequence); + } + validate_body_predecessors(pre_state, body)?; + if body.resulting_epoch() != pre_state.expected_epoch_for(body.operation())? { + return Err(IdentityError::InvalidEpoch); + } + + let proposal_id = body.proposal_id()?; + if approvals.proposal_id() != proposal_id { + return Err(IdentityError::InvalidRelationship { + resource: "event intent proposal", + }); + } + let rule = pre_state + .control_policy() + .rule_for(body.operation().kind()) + .ok_or(IdentityError::AuthorizationDenied)?; + if rule.delay().is_none() + && !matches!(body.operation(), crate::AccountOperation::BeginRecovery(_)) + { + return Err(IdentityError::InvalidRelationship { + resource: "provider event intent without policy delay", + }); + } + + match body.operation() { + crate::AccountOperation::BeginRecovery(_) | crate::AccountOperation::CancelRecovery(_) => { + validate_recovery_intent_authority(pre_state, body, approvals)?; + } + crate::AccountOperation::FinalizeRecovery(_) => { + // Finalization is authorized by the pending recovery plus nested provider evidence, + // never by an unrelated controller-intent threshold. + return Err(IdentityError::AuthorizationDenied); + } + _ => validate_intent_threshold( + pre_state, + body, + approvals, + rule.eligible_controllers(), + rule.required_weight(), + )?, + } + + Ok(VerifiedEventIntent { + account_id: pre_state.account_id(), + proposal_id, + }) +} + +/// Verify guardian authority embedded in one exact recovery intent at one provider observation. +/// +/// The provider observation time is bound into the resulting opaque append admission. Provider +/// receipts later establish quorum authority time and the event path re-verifies the same embedded +/// guardian set; this pre-admission check cannot replace that final validation. +pub(crate) fn verify_guardian_recovery_intent( + pre_state: &AccountState, + body: &crate::EventBody, + observed_at: crate::Timestamp, +) -> Result { + if body.account_id() != pre_state.account_id() { + return Err(IdentityError::AccountMismatch); + } + if body.sequence() != pre_state.sequence().checked_next()? { + return Err(IdentityError::InvalidSequence); + } + validate_body_predecessors(pre_state, body)?; + if body.resulting_epoch() != pre_state.expected_epoch_for(body.operation())? { + return Err(IdentityError::InvalidEpoch); + } + pre_state + .control_policy() + .rule_for(body.operation().kind()) + .ok_or(IdentityError::AuthorizationDenied)?; + + let (threshold_evidence, recovery_id, decision) = match body.operation() { + crate::AccountOperation::BeginRecovery(begin) => ( + begin.threshold_evidence(), + begin.recovery_id(), + crate::GuardianApprovalDecision::Begin, + ), + crate::AccountOperation::CancelRecovery(cancel) => ( + cancel.threshold_evidence(), + cancel.expected_pending_recovery(), + crate::GuardianApprovalDecision::Cancel, + ), + _ => { + return Err(IdentityError::InvalidRelationship { + resource: "guardian recovery intent operation", + }); + } + }; + if threshold_evidence.recovery_policy_id() != pre_state.recovery_policy_id() + || threshold_evidence.recovery_policy_version() + != pre_state.recovery_policy().policy_version() + { + return Err(IdentityError::PolicyVersionMismatch); + } + if !matches!( + pre_state.recovery_policy().authority(), + crate::RecoveryAuthority::GuardianThreshold(_) + ) { + return Err(IdentityError::InvalidRelationship { + resource: "guardian intent under controller recovery policy", + }); + } + let approvals = threshold_evidence + .as_guardian_approvals() + .ok_or(IdentityError::AuthorizationDenied)?; + let context = crate::GuardianAuthorityContext::try_new( + pre_state.account_id(), + recovery_id, + pre_state.recovery_policy_id(), + pre_state.recovery_policy().policy_version(), + pre_state.epoch(), + decision, + observed_at, + )?; + crate::verify_guardian_authority(pre_state.recovery_policy(), approvals, &context)?; + Ok(VerifiedEventIntent { + account_id: pre_state.account_id(), + proposal_id: body.proposal_id()?, + }) +} + +/// Validate envelope binding and weighted authorization against an immutable pre-state. +pub(crate) fn validate_event( + lineage: &AccountState, + authority: &AccountState, + event: &AuthorizedEvent, + expected_epoch: crate::Epoch, +) -> Result { + let body = event.body(); + if body.account_id() != lineage.account_id() { + return Err(IdentityError::AccountMismatch); + } + if body.sequence() != lineage.sequence().checked_next()? { + return Err(IdentityError::InvalidSequence); + } + validate_predecessors(lineage, event)?; + if body.resulting_epoch() != expected_epoch { + return Err(IdentityError::InvalidEpoch); + } + + let proposal_id = body.proposal_id()?; + let evidence = event.admission_evidence(); + if evidence.proposal_id() != proposal_id { + return Err(IdentityError::InvalidRelationship { + resource: "projected event admission subject", + }); + } + if evidence.provider_policy_id() != authority.provider_policy_id() { + return Err(IdentityError::PolicyVersionMismatch); + } + + let rule = authority + .control_policy() + .rule_for(body.operation().kind()) + .ok_or(IdentityError::AuthorizationDenied)?; + if matches!( + body.operation(), + crate::AccountOperation::BeginRecovery(_) | crate::AccountOperation::FinalizeRecovery(_) + ) { + authority.require_v1_recovery_crypto()?; + } + let provider_authority_time = validate_freshness(authority, event, rule)?; + match body.operation() { + crate::AccountOperation::BeginRecovery(_) | crate::AccountOperation::CancelRecovery(_) => { + validate_recovery_authority(authority, event, provider_authority_time)?; + } + crate::AccountOperation::FinalizeRecovery(_) => { + if !event.approvals().as_slice().is_empty() { + return Err(IdentityError::InvalidRelationship { + resource: "finalize recovery controller approvals", + }); + } + } + _ => validate_approvals(authority, event, rule)?, + } + Ok(ValidatedEvent { + provider_authority_time, + }) +} + +fn validate_predecessors( + lineage: &AccountState, + event: &AuthorizedEvent, +) -> Result<(), IdentityError> { + validate_body_predecessors(lineage, event.body()) +} + +fn validate_body_predecessors( + lineage: &AccountState, + body: &crate::EventBody, +) -> Result<(), IdentityError> { + let predecessors = body.predecessors(); + if lineage.sequence() == crate::Sequence::GENESIS { + if predecessors.genesis_anchor() != Some(lineage.genesis_anchor()) { + return Err(IdentityError::InvalidPredecessor); + } + return Ok(()); + } + if predecessors.event_heads() != Some(lineage.heads()) { + return Err(IdentityError::InvalidPredecessor); + } + Ok(()) +} + +fn validate_freshness( + authority: &AccountState, + event: &AuthorizedEvent, + rule: &crate::PolicyRule, +) -> Result, IdentityError> { + let evidence = event.admission_evidence(); + let mut valid_completion_receipts = Vec::new(); + let mut rule_provider_quorum = 0_usize; + let mut provider_authority_time = None; + match rule.freshness() { + FreshnessRequirement::LatestKnown => {} + FreshnessRequirement::ProviderQuorum(requirement) => { + let receipts = evidence + .freshness() + .provider_receipts() + .ok_or(IdentityError::FreshnessUnavailable)?; + if evidence.freshness().provider_policy_id() != Some(authority.provider_policy_id()) { + return Err(IdentityError::PolicyVersionMismatch); + } + let policy = match authority.provider_policy().mode() { + ProviderMode::LocalOnly => return Err(IdentityError::FreshnessUnavailable), + ProviderMode::Replicated(policy) => policy, + }; + rule_provider_quorum = usize::from(requirement.required().get()); + let required = + rule_provider_quorum.max(usize::from(policy.sufficient_threshold().get())); + let maximum_age = requirement + .maximum_age() + .get() + .min(policy.maximum_evidence_age().get()); + let mut stale_configured_receipt = false; + for receipt in receipts.as_slice() { + if receipt.entry().account_id() != authority.account_id() { + return Err(IdentityError::AccountMismatch); + } + let Some(provider) = + configured_provider(policy.providers(), receipt.provider_id())? + else { + continue; + }; + receipt.verify(provider)?; + let entry_time = receipt.entry().observed_at().as_unix_millis(); + let head_time = receipt.signed_head().body().observed_at().as_unix_millis(); + let age = head_time.checked_sub(entry_time).ok_or( + IdentityError::InvalidRelationship { + resource: "provider head observation time", + }, + )?; + if age > maximum_age { + stale_configured_receipt = true; + continue; + } + valid_completion_receipts.push(receipt); + } + if valid_completion_receipts.len() < required { + return Err(if stale_configured_receipt { + IdentityError::StaleEvidence + } else { + IdentityError::FreshnessUnavailable + }); + } + let mut authority_times = valid_completion_receipts + .iter() + .map(|receipt| receipt.signed_head().body().observed_at()) + .collect::>(); + authority_times.sort_unstable(); + provider_authority_time = Some(authority_times[required - 1]); + } + } + + let requires_recovery_observation = matches!( + event.body().operation(), + crate::AccountOperation::BeginRecovery(_) + ) || matches!( + ( + event.body().operation(), + authority.recovery_policy().authority(), + ), + ( + crate::AccountOperation::CancelRecovery(_), + crate::RecoveryAuthority::GuardianThreshold(_), + ) + ); + if requires_recovery_observation { + return validate_recovery_intent_observation( + authority, + event, + rule, + rule_provider_quorum, + provider_authority_time, + ); + } + + match rule.delay() { + None if evidence.delay().observed_at().is_none() => {} + None => { + return Err(IdentityError::InvalidRelationship { + resource: "unexpected policy delay evidence", + }); + } + Some(delay) => { + let delay_anchor = evidence + .delay() + .observed_at() + .ok_or(IdentityError::FreshnessUnavailable)?; + if evidence.delay().provider_policy_id() != Some(authority.provider_policy_id()) { + return Err(IdentityError::PolicyVersionMismatch); + } + let policy = match authority.provider_policy().mode() { + ProviderMode::LocalOnly => return Err(IdentityError::FreshnessUnavailable), + ProviderMode::Replicated(policy) => policy, + }; + let declared_delay_quorum = evidence + .delay() + .required_quorum() + .ok_or(IdentityError::FreshnessUnavailable)?; + let minimum_required = + usize::from(policy.sufficient_threshold().get()).max(rule_provider_quorum); + if usize::from(declared_delay_quorum.get()) < minimum_required { + return Err(IdentityError::FreshnessUnavailable); + } + let required = minimum_required.max(usize::from(declared_delay_quorum.get())); + let delay_receipts = evidence + .delay() + .provider_receipts() + .ok_or(IdentityError::FreshnessUnavailable)?; + let mut configured_delay_receipts = Vec::new(); + for receipt in delay_receipts.as_slice() { + if receipt.entry().account_id() != authority.account_id() { + return Err(IdentityError::AccountMismatch); + } + let Some(provider) = + configured_provider(policy.providers(), receipt.provider_id())? + else { + continue; + }; + receipt.verify(provider)?; + configured_delay_receipts.push(receipt); + } + if configured_delay_receipts.len() < required { + return Err(IdentityError::FreshnessUnavailable); + } + let mut configured_delay_observations = configured_delay_receipts + .iter() + .map(|receipt| receipt.entry().observed_at()) + .collect::>(); + configured_delay_observations.sort_unstable(); + if configured_delay_observations[required - 1] != delay_anchor { + return Err(IdentityError::InvalidRelationship { + resource: "configured-provider delay observation anchor", + }); + } + validate_intent_approvals(authority, event, rule)?; + let deadline = delay_anchor.checked_add(delay)?; + let completion_receipts = + if matches!(rule.freshness(), FreshnessRequirement::LatestKnown) { + configured_delay_receipts + } else { + valid_completion_receipts + }; + let mut elapsed_authority_times = completion_receipts + .iter() + .map(|receipt| receipt.signed_head().body().observed_at()) + .filter(|observed_at| *observed_at >= deadline) + .collect::>(); + if elapsed_authority_times.len() < required { + return Err(IdentityError::DelayNotElapsed); + } + elapsed_authority_times.sort_unstable(); + provider_authority_time = Some(elapsed_authority_times[required - 1]); + } + } + Ok(provider_authority_time) +} + +fn validate_recovery_intent_observation( + authority: &AccountState, + event: &AuthorizedEvent, + rule: &crate::PolicyRule, + rule_provider_quorum: usize, + provider_authority_time: Option, +) -> Result, IdentityError> { + // Begin uses this verified observation to start the mandatory recovery delay. Guardian Cancel + // uses the same exact-proposal observation only to authenticate authority time; cancellation + // itself never consumes or waits for a delay interval. + let evidence = event.admission_evidence(); + let delay_anchor = evidence + .delay() + .observed_at() + .ok_or(IdentityError::FreshnessUnavailable)?; + if evidence.delay().provider_policy_id() != Some(authority.provider_policy_id()) { + return Err(IdentityError::PolicyVersionMismatch); + } + let policy = match authority.provider_policy().mode() { + ProviderMode::LocalOnly => return Err(IdentityError::FreshnessUnavailable), + ProviderMode::Replicated(policy) => policy, + }; + let declared_quorum = evidence + .delay() + .required_quorum() + .ok_or(IdentityError::FreshnessUnavailable)?; + let minimum_required = + usize::from(policy.sufficient_threshold().get()).max(rule_provider_quorum); + if usize::from(declared_quorum.get()) < minimum_required { + return Err(IdentityError::FreshnessUnavailable); + } + let required = minimum_required.max(usize::from(declared_quorum.get())); + let receipts = evidence + .delay() + .provider_receipts() + .ok_or(IdentityError::FreshnessUnavailable)?; + let mut configured_receipts = Vec::new(); + for receipt in receipts.as_slice() { + if receipt.entry().account_id() != authority.account_id() { + return Err(IdentityError::AccountMismatch); + } + let Some(provider) = configured_provider(policy.providers(), receipt.provider_id())? else { + continue; + }; + receipt.verify(provider)?; + configured_receipts.push(receipt); + } + if configured_receipts.len() < required { + return Err(IdentityError::FreshnessUnavailable); + } + let mut observations = configured_receipts + .iter() + .map(|receipt| receipt.entry().observed_at()) + .collect::>(); + observations.sort_unstable(); + if observations[required - 1] != delay_anchor { + return Err(IdentityError::InvalidRelationship { + resource: "configured-provider recovery intent observation anchor", + }); + } + validate_intent_approvals(authority, event, rule)?; + let mut authority_times = configured_receipts + .iter() + .map(|receipt| receipt.signed_head().body().observed_at()) + .collect::>(); + authority_times.sort_unstable(); + let start_authority_time = authority_times[required - 1]; + Ok(Some( + provider_authority_time.map_or(start_authority_time, |freshness_time| { + freshness_time.max(start_authority_time) + }), + )) +} + +pub(crate) fn configured_provider( + providers: &[crate::ProviderDescriptor], + provider_id: crate::ProviderId, +) -> Result, IdentityError> { + for provider in providers { + if provider.id()? == provider_id { + return Ok(Some(provider)); + } + } + Ok(None) +} + +fn validate_intent_approvals( + authority: &AccountState, + event: &AuthorizedEvent, + rule: &crate::PolicyRule, +) -> Result<(), IdentityError> { + let proposal_id = event.body().proposal_id()?; + let delay = event.admission_evidence().delay(); + let receipts = event + .admission_evidence() + .delay() + .provider_receipts() + .ok_or(IdentityError::FreshnessUnavailable)?; + if receipts.as_slice().iter().any(|receipt| { + receipt.entry().subject() != crate::ProviderLogSubject::EventIntent(proposal_id) + }) { + return Err(IdentityError::InvalidRelationship { + resource: "delayed intent receipt subject", + }); + } + + let (selector, required_weight) = match event.body().operation() { + crate::AccountOperation::BeginRecovery(_) | crate::AccountOperation::CancelRecovery(_) => { + match authority.recovery_policy().authority() { + crate::RecoveryAuthority::ControllerThreshold(threshold) => { + if delay.is_guardian_recovery() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian delay evidence under controller recovery policy", + }); + } + (threshold.selector(), threshold.required_weight()) + } + crate::RecoveryAuthority::GuardianThreshold(_) => { + // The proposal ID already commits the embedded guardian approval set. The + // provider receipts bind that exact proposal; unrelated controller intent + // signatures are not recovery authority. + if !delay.is_guardian_recovery() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian recovery delay evidence shape", + }); + } + return Ok(()); + } + } + } + _ => { + if delay.is_guardian_recovery() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian delay evidence for ordinary operation", + }); + } + (rule.eligible_controllers(), rule.required_weight()) + } + }; + + let intent_approvals = delay + .intent_approvals() + .ok_or(IdentityError::FreshnessUnavailable)?; + if intent_approvals.proposal_id() != proposal_id { + return Err(IdentityError::InvalidRelationship { + resource: "delayed intent proposal", + }); + } + + validate_intent_threshold( + authority, + event.body(), + intent_approvals, + selector, + required_weight, + ) +} + +fn validate_intent_threshold( + authority: &AccountState, + body: &crate::EventBody, + intent_approvals: &crate::EventIntentApprovals, + selector: &crate::ControllerSelector, + required_weight: crate::RequiredWeight, +) -> Result<(), IdentityError> { + let mut total_weight = 0_u64; + let mut previous_signer = None; + for approval in intent_approvals.as_slice() { + let controller_id = approval.body().controller_id(); + if previous_signer == Some(controller_id) { + return Err(IdentityError::DuplicateSigner); + } + previous_signer = Some(controller_id); + let controller = approval_controller(authority, controller_id)?; + if !controller + .descriptor() + .scope() + .allows(body.operation().kind()) + || !selector.matches_controller(controller.descriptor())? + { + return Err(IdentityError::IneligibleController); + } + let keys = authority.verification_keys(controller_id)?; + if keys.len() != approval.signatures().len() { + return Err(IdentityError::InvalidSignature); + } + let signed_bytes = approval.body().to_canonical_bytes()?; + for expected in &keys { + let signature = approval + .signatures() + .iter() + .find(|signature| { + signature.crypto_suite_id() == expected.crypto_suite_id + && signature.controller_key_id() == expected.controller_key_id + }) + .ok_or(IdentityError::InvalidSignature)?; + verify_algorithm_signature( + expected.algorithm_code, + &expected.public_key, + signature.signature(), + &signed_bytes, + )?; + } + total_weight = total_weight + .checked_add(u64::from(controller.descriptor().weight().get())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "event intent authorization weight", + })?; + } + if total_weight < u64::from(required_weight.get()) { + return Err(IdentityError::AuthorizationDenied); + } + Ok(()) +} + +fn validate_recovery_intent_authority( + authority: &AccountState, + body: &crate::EventBody, + intent_approvals: &crate::EventIntentApprovals, +) -> Result<(), IdentityError> { + let evidence = match body.operation() { + crate::AccountOperation::BeginRecovery(begin) => begin.threshold_evidence(), + crate::AccountOperation::CancelRecovery(cancel) => cancel.threshold_evidence(), + _ => { + return Err(IdentityError::InvalidRelationship { + resource: "non-recovery event under recovery intent authority", + }); + } + }; + if evidence.recovery_policy_id() != authority.recovery_policy_id() + || evidence.recovery_policy_version() != authority.recovery_policy().policy_version() + { + return Err(IdentityError::PolicyVersionMismatch); + } + + match authority.recovery_policy().authority() { + crate::RecoveryAuthority::ControllerThreshold(threshold) => { + if evidence.as_guardian_approvals().is_some() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian evidence for controller recovery intent", + }); + } + validate_intent_threshold( + authority, + body, + intent_approvals, + threshold.selector(), + threshold.required_weight(), + ) + } + crate::RecoveryAuthority::GuardianThreshold(_) => { + // Exact guardian membership and expiry require an authenticated authority time. This + // pre-admission API intentionally has no caller-supplied time escape hatch. + Err(IdentityError::FreshnessUnavailable) + } + } +} + +fn validate_approvals( + authority: &AccountState, + event: &AuthorizedEvent, + rule: &crate::PolicyRule, +) -> Result<(), IdentityError> { + let mut total_weight = 0_u64; + let mut previous_signer: Option = None; + for approval in event.approvals().as_slice() { + let controller_id = approval.body().controller_id(); + if previous_signer == Some(controller_id) { + return Err(IdentityError::DuplicateSigner); + } + previous_signer = Some(controller_id); + let controller = approval_controller(authority, controller_id)?; + if !controller + .descriptor() + .scope() + .allows(event.body().operation().kind()) + || !rule + .eligible_controllers() + .matches_controller(controller.descriptor())? + { + return Err(IdentityError::IneligibleController); + } + verify_controller_approval(authority, approval)?; + + total_weight = total_weight + .checked_add(u64::from(controller.descriptor().weight().get())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "controller authorization weight", + })?; + } + + if total_weight < u64::from(rule.required_weight().get()) { + return Err(IdentityError::AuthorizationDenied); + } + Ok(()) +} + +fn approval_controller( + authority: &AccountState, + controller_id: ControllerId, +) -> Result<&crate::ProjectedController, IdentityError> { + match authority.active_controller(controller_id) { + Some(controller) => Ok(controller), + None if authority.revoked_controller(controller_id).is_some() => { + Err(IdentityError::RevokedController) + } + None => Err(IdentityError::UnknownController), + } +} + +pub(crate) fn verify_controller_approval( + authority: &AccountState, + approval: &crate::SignedControllerApproval, +) -> Result<(), IdentityError> { + let controller_id = approval.body().controller_id(); + let signed_bytes = approval.body().to_canonical_bytes()?; + let keys = authority.verification_keys(controller_id)?; + if approval.signatures().len() != keys.len() { + return Err(IdentityError::InvalidSignature); + } + for expected in &keys { + let keyed_signature = approval + .signatures() + .iter() + .find(|signature| { + signature.crypto_suite_id() == expected.crypto_suite_id + && signature.controller_key_id() == expected.controller_key_id + }) + .ok_or(IdentityError::InvalidSignature)?; + if keyed_signature.signature().algorithm_code() != expected.algorithm_code { + return Err(IdentityError::InvalidSignature); + } + verify_algorithm_signature( + expected.algorithm_code, + expected.public_key.as_slice(), + keyed_signature.signature(), + &signed_bytes, + )?; + } + Ok(()) +} + +/// Verify direct checkpoint attestations under the authority that governs provider policy. +/// +/// Checkpoint publication is not an account-state transition in v1, so it has no account-operation +/// codepoint of its own. The frozen v1 rule is to reuse the current `ChangeProviderPolicy` selector +/// and weighted threshold: the controllers allowed to choose the transparency set are the ones +/// allowed to authorize a directly published checkpoint to that set. +pub(crate) fn verify_checkpoint_approvals( + authority: &AccountState, + approvals: &crate::ControllerApprovals, +) -> Result<(), IdentityError> { + let operation = crate::OperationKind::ChangeProviderPolicy; + let rule = authority + .control_policy() + .rule_for(operation) + .ok_or(IdentityError::AuthorizationDenied)?; + let mut total_weight = 0_u64; + let mut previous_signer = None; + for approval in approvals.as_slice() { + let controller_id = approval.body().controller_id(); + if previous_signer == Some(controller_id) { + return Err(IdentityError::DuplicateSigner); + } + previous_signer = Some(controller_id); + let controller = approval_controller(authority, controller_id)?; + if !controller.descriptor().scope().allows(operation) + || !rule + .eligible_controllers() + .matches_controller(controller.descriptor())? + { + return Err(IdentityError::IneligibleController); + } + verify_controller_approval(authority, approval)?; + total_weight = total_weight + .checked_add(u64::from(controller.descriptor().weight().get())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "checkpoint controller authorization weight", + })?; + } + if total_weight < u64::from(rule.required_weight().get()) { + return Err(IdentityError::AuthorizationDenied); + } + Ok(()) +} + +fn verify_ed25519( + public_key: &[u8], + signature: &[u8], + message: &[u8], +) -> Result<(), IdentityError> { + let public_key: &[u8; 32] = public_key + .try_into() + .map_err(|_| IdentityError::InvalidSignature)?; + let public_key = + PublicKey::from_bytes(public_key).map_err(|_| IdentityError::InvalidSignature)?; + let signature = Signature::try_from(signature).map_err(|_| IdentityError::InvalidSignature)?; + public_key + .verify(message, &signature) + .map_err(|_| IdentityError::InvalidSignature) +} + +fn validate_recovery_authority( + authority: &AccountState, + event: &AuthorizedEvent, + provider_authority_time: Option, +) -> Result<(), IdentityError> { + let (evidence, recovery_id, expected_decision, operation_kind) = match event.body().operation() + { + crate::AccountOperation::BeginRecovery(begin) => ( + begin.threshold_evidence(), + begin.recovery_id(), + crate::GuardianApprovalDecision::Begin, + crate::OperationKind::BeginRecovery, + ), + crate::AccountOperation::CancelRecovery(cancel) => ( + cancel.threshold_evidence(), + cancel.expected_pending_recovery(), + crate::GuardianApprovalDecision::Cancel, + crate::OperationKind::CancelRecovery, + ), + _ => return Ok(()), + }; + if evidence.recovery_policy_id() != authority.recovery_policy_id() + || evidence.recovery_policy_version() != authority.recovery_policy().policy_version() + { + return Err(IdentityError::PolicyVersionMismatch); + } + + match authority.recovery_policy().authority() { + crate::RecoveryAuthority::ControllerThreshold(threshold) => { + if evidence.as_guardian_approvals().is_some() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian evidence for controller recovery authority", + }); + } + let mut total = 0_u64; + let mut previous_signer = None; + for approval in event.approvals().as_slice() { + let controller_id = approval.body().controller_id(); + if previous_signer == Some(controller_id) { + return Err(IdentityError::DuplicateSigner); + } + previous_signer = Some(controller_id); + let controller = approval_controller(authority, controller_id)?; + if threshold + .selector() + .matches_controller(controller.descriptor())? + && controller.descriptor().scope().allows(operation_kind) + { + total = total + .checked_add(u64::from(controller.descriptor().weight().get())) + .ok_or(IdentityError::ArithmeticOverflow { + resource: "controller recovery weight", + })?; + } else { + return Err(IdentityError::IneligibleController); + } + verify_controller_approval(authority, approval)?; + } + if total < u64::from(threshold.required_weight().get()) { + return Err(IdentityError::AuthorizationDenied); + } + Ok(()) + } + crate::RecoveryAuthority::GuardianThreshold(_) => { + if !event.approvals().as_slice().is_empty() { + return Err(IdentityError::InvalidRelationship { + resource: "guardian recovery controller approvals", + }); + } + let approvals = evidence + .as_guardian_approvals() + .ok_or(IdentityError::AuthorizationDenied)?; + let authority_time = + provider_authority_time.ok_or(IdentityError::FreshnessUnavailable)?; + let context = crate::GuardianAuthorityContext::try_new( + authority.account_id(), + recovery_id, + authority.recovery_policy_id(), + authority.recovery_policy().policy_version(), + authority.epoch(), + expected_decision, + authority_time, + )?; + crate::verify_guardian_authority(authority.recovery_policy(), approvals, &context) + .map(|_| ()) + } + } +} + +pub(crate) fn verify_algorithm_signature( + algorithm_code: u16, + public_key: &[u8], + signature: &AlgorithmSignature, + message: &[u8], +) -> Result<(), IdentityError> { + if signature.algorithm_code() != algorithm_code { + return Err(IdentityError::InvalidSignature); + } + match algorithm_code { + 1 => verify_ed25519(public_key, signature.as_bytes(), message), + code => Err(IdentityError::UnsupportedAlgorithm { + kind: AlgorithmKind::Signature, + code, + }), + } +} diff --git a/protocols/krikos-identity/tests/account_event_schema.rs b/protocols/krikos-identity/tests/account_event_schema.rs new file mode 100644 index 00000000000..6b8a6ebff00 --- /dev/null +++ b/protocols/krikos-identity/tests/account_event_schema.rs @@ -0,0 +1,185 @@ +use krikos_identity::{ + AccountId, AccountOperation, AdmissionEvidence, AlgorithmSignature, CanonicalWire, + CheckpointId, ControllerApprovalBody, ControllerApprovals, ControllerId, ControllerKeyId, + CryptoSuiteId, Digest, Epoch, EventBody, EventId, EventPredecessors, Extensions, + FreshnessEvidence, HashAlgorithm, IdentityError, KeyedSignature, ProposalId, ProviderPolicy, + ProviderPolicyId, ProviderPolicyVersion, Sequence, SignedControllerApproval, Timestamp, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn body() -> EventBody { + EventBody::new( + typed_id::(1), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id(2)), + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Timestamp::from_unix_millis(3), + [4; 16], + Extensions::default(), + ) + .unwrap() +} + +#[test] +fn event_body_intent_and_admitted_event_have_distinct_stable_domains() { + let body = body(); + let proposal_id = body.proposal_id().unwrap(); + let checkpoint_id = typed_id::(5); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + typed_id::(6), + FreshnessEvidence::local_known(checkpoint_id), + krikos_identity::DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + assert_eq!( + hex::encode(body.to_canonical_bytes().unwrap()), + "010101010101010101010101010101010101010101010101010101010101010101010101010102020202020202020202020202020202020202020202020202020202020202020c0101010000030404040404040404040404040404040400" + ); + assert_eq!( + proposal_id.to_string(), + "b3:e70c3c5bb4f72daaa52f5f4ad31e7c4dee4e94f1cafd40563c51a92ababa2da0" + ); + assert_eq!( + event_id.to_string(), + "b3:b35f2d617ddaff7c1833c33098da212cc47a52dc06453a4fa7f1bcb0be59b4ff" + ); + assert_ne!(proposal_id.as_digest(), event_id.as_digest()); + assert_eq!(body.operation().kind().code(), 12); + assert_eq!( + EventBody::from_canonical_bytes(&body.to_canonical_bytes().unwrap()).unwrap(), + body + ); +} + +#[test] +fn event_predecessor_heads_are_complete_sorted_and_unique() { + let first = typed_id::(1); + let second = typed_id::(2); + let heads = EventPredecessors::events(vec![second, first]).unwrap(); + assert_eq!(heads.event_heads().unwrap(), &[first, second]); + assert!(matches!( + EventPredecessors::events(vec![]), + Err(IdentityError::EmptyCollection { .. }) + )); + assert!(matches!( + EventPredecessors::events(vec![first, first]), + Err(IdentityError::DuplicateElement { .. }) + )); + let unsorted = postcard::to_stdvec(&(2_u16, vec![second, first])).unwrap(); + assert!(matches!( + EventPredecessors::from_canonical_bytes(&unsorted), + Err(IdentityError::NonCanonical) + )); +} + +#[test] +fn event_operation_registry_rejects_reserved_and_unknown_codes() { + let operation = AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()).unwrap(), + ); + let bytes = operation.to_canonical_bytes().unwrap(); + assert_eq!(bytes[0], 12); + assert_eq!( + AccountOperation::from_canonical_bytes(&bytes).unwrap(), + operation + ); + assert!(matches!( + AccountOperation::from_canonical_bytes(&[23]), + Err(IdentityError::ReservedCodepoint { code: 23, .. }) + )); + assert!(matches!( + AccountOperation::from_canonical_bytes(&[24]), + Err(IdentityError::UnsupportedCodepoint { code: 24, .. }) + )); +} + +#[test] +fn authorized_event_binds_body_admission_and_every_approval() { + let body = body(); + let proposal_id = body.proposal_id().unwrap(); + let checkpoint_id = typed_id::(5); + let provider_policy_id = typed_id::(6); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + provider_policy_id, + FreshnessEvidence::local_known(checkpoint_id), + krikos_identity::DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let evidence_id = evidence.admission_evidence_id().unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + let approval = SignedControllerApproval::new( + ControllerApprovalBody::event( + typed_id::(7), + event_id, + evidence_id, + Extensions::default(), + ) + .unwrap(), + vec![KeyedSignature::new( + typed_id::(8), + typed_id::(9), + AlgorithmSignature::new(1, vec![10; 64]).unwrap(), + )], + ) + .unwrap(); + let approvals = ControllerApprovals::new(vec![approval]).unwrap(); + let authorized = + krikos_identity::AuthorizedEvent::new(body.clone(), evidence, approvals).unwrap(); + assert_eq!(authorized.event_id().unwrap(), event_id); + assert_eq!( + krikos_identity::AuthorizedEvent::from_canonical_bytes( + &authorized.to_canonical_bytes().unwrap() + ) + .unwrap(), + authorized + ); + + let wrong_event = typed_id::(11); + let wrong_evidence = AdmissionEvidence::new( + typed_id::(12), + checkpoint_id, + provider_policy_id, + FreshnessEvidence::local_known(checkpoint_id), + krikos_identity::DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let wrong_approval = SignedControllerApproval::new( + ControllerApprovalBody::event( + typed_id::(7), + wrong_event, + wrong_evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(), + vec![KeyedSignature::new( + typed_id::(8), + typed_id::(9), + AlgorithmSignature::new(1, vec![10; 64]).unwrap(), + )], + ) + .unwrap(); + assert!(matches!( + krikos_identity::AuthorizedEvent::new( + body, + wrong_evidence, + ControllerApprovals::new(vec![wrong_approval]).unwrap(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} diff --git a/protocols/krikos-identity/tests/application_verification.rs b/protocols/krikos-identity/tests/application_verification.rs new file mode 100644 index 00000000000..0e0872ec78e --- /dev/null +++ b/protocols/krikos-identity/tests/application_verification.rs @@ -0,0 +1,340 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, AgreementPublicKey, ApplicationAuthorizationView, ApplicationDeviceStatus, + ApplicationEventBody, ApplicationEventCounter, ApplicationId, AuthorizationContext, + CanonicalWire, CheckpointId, DeviceAuthorization, DeviceClass, DeviceDescriptor, Digest, + EndpointPublicKey, Epoch, Extensions, HashAlgorithm, IdentityError, ProtocolSignature, + SignedApplicationEvent, SigningPublicKey, verify_application_event, +}; + +fn typed_id(seed: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [seed; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn fixture_authorization(secret: &SecretKey) -> DeviceAuthorization { + let endpoint_secret = SecretKey::from_bytes(&[0x32; 32]); + let descriptor = DeviceDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + AgreementPublicKey::x25519([ + 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]) + .unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*endpoint_secret.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap(); + DeviceAuthorization::new( + descriptor.id().unwrap(), + descriptor, + DeviceClass::ApplicationOnly, + None, + Vec::new(), + Epoch::new(3), + Extensions::default(), + ) + .unwrap() +} + +fn context() -> AuthorizationContext { + AuthorizationContext::new(typed_id::(0x41), Epoch::new(7), typed_id(0x42)) +} + +fn signed_event( + secret: &SecretKey, + authorization: &DeviceAuthorization, + context: AuthorizationContext, + payload: Vec, +) -> SignedApplicationEvent { + let body = ApplicationEventBody::new( + context.account_id(), + ApplicationId::new(Digest::new(HashAlgorithm::Blake3_256, [0x43; 32])), + authorization.device_id(), + context.epoch(), + context.checkpoint_id(), + ApplicationEventCounter::new(11), + payload, + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&body.signing_bytes().unwrap()); + SignedApplicationEvent::new(body, ProtocolSignature::ed25519(signature.to_bytes())).unwrap() +} + +struct View<'a> { + context: AuthorizationContext, + status: ApplicationDeviceStatus, + authorization: Option<&'a DeviceAuthorization>, +} + +impl ApplicationAuthorizationView for View<'_> { + fn authorization_context(&self) -> AuthorizationContext { + self.context + } + + fn device_status(&self, _device_id: krikos_identity::DeviceId) -> ApplicationDeviceStatus { + self.status + } + + fn device_authorization( + &self, + _device_id: krikos_identity::DeviceId, + ) -> Option<&DeviceAuthorization> { + self.authorization + } +} + +#[test] +fn application_signature_is_bound_to_exact_body_and_known_context() { + let secret = SecretKey::from_bytes(&[0x31; 32]); + let authorization = fixture_authorization(&secret); + let event = signed_event(&secret, &authorization, context(), b"payload".to_vec()); + let view = View { + context: context(), + status: ApplicationDeviceStatus::Active, + authorization: Some(&authorization), + }; + + assert_eq!( + verify_application_event(&event, &view).unwrap(), + event.application_event_id().unwrap() + ); + + let tampered_body = ApplicationEventBody::new( + event.body().account_id(), + event.body().application_id(), + event.body().device_id(), + event.body().account_epoch(), + event.body().checkpoint_id(), + event.body().local_counter(), + b"tampered".to_vec(), + Extensions::default(), + ) + .unwrap(); + let tampered = SignedApplicationEvent::new(tampered_body, event.signature()).unwrap(); + assert_eq!( + verify_application_event(&tampered, &view), + Err(IdentityError::InvalidSignature) + ); +} + +#[test] +fn application_verification_fails_closed_for_wrong_basis_or_device_status() { + let secret = SecretKey::from_bytes(&[0x31; 32]); + let authorization = fixture_authorization(&secret); + let event = signed_event(&secret, &authorization, context(), Vec::new()); + + for (status, expected) in [ + ( + ApplicationDeviceStatus::Unknown, + IdentityError::DeviceNotAuthorized, + ), + ( + ApplicationDeviceStatus::Suspended, + IdentityError::DeviceSuspended, + ), + ( + ApplicationDeviceStatus::Revoked, + IdentityError::DeviceRevoked, + ), + ] { + let view = View { + context: context(), + status, + authorization: Some(&authorization), + }; + assert_eq!(verify_application_event(&event, &view), Err(expected)); + } + + let missing = View { + context: context(), + status: ApplicationDeviceStatus::Active, + authorization: None, + }; + assert_eq!( + verify_application_event(&event, &missing), + Err(IdentityError::DeviceNotAuthorized) + ); + + let wrong_account = View { + context: AuthorizationContext::new( + typed_id::(0x51), + context().epoch(), + context().checkpoint_id(), + ), + status: ApplicationDeviceStatus::Active, + authorization: Some(&authorization), + }; + assert_eq!( + verify_application_event(&event, &wrong_account), + Err(IdentityError::AccountMismatch) + ); + + let wrong_epoch = View { + context: AuthorizationContext::new( + context().account_id(), + Epoch::new(8), + context().checkpoint_id(), + ), + status: ApplicationDeviceStatus::Active, + authorization: Some(&authorization), + }; + assert_eq!( + verify_application_event(&event, &wrong_epoch), + Err(IdentityError::InvalidEpoch) + ); + + let wrong_checkpoint = View { + context: AuthorizationContext::new( + context().account_id(), + context().epoch(), + typed_id::(0x52), + ), + status: ApplicationDeviceStatus::Active, + authorization: Some(&authorization), + }; + assert!(matches!( + verify_application_event(&event, &wrong_checkpoint), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn application_verification_rejects_forged_or_not_yet_authorized_signers() { + let secret = SecretKey::from_bytes(&[0x31; 32]); + let authorization = fixture_authorization(&secret); + let event = signed_event(&secret, &authorization, context(), Vec::new()); + let view = View { + context: context(), + status: ApplicationDeviceStatus::Active, + authorization: Some(&authorization), + }; + + let attacker = SecretKey::from_bytes(&[0x33; 32]); + let forged = SignedApplicationEvent::new( + event.body().clone(), + ProtocolSignature::ed25519( + attacker + .sign(&event.body().signing_bytes().unwrap()) + .to_bytes(), + ), + ) + .unwrap(); + assert_eq!( + verify_application_event(&forged, &view), + Err(IdentityError::InvalidSignature) + ); + + let future_authorization = DeviceAuthorization::new( + authorization.device_id(), + authorization.descriptor().clone(), + authorization.device_class(), + authorization.metadata_commitment(), + authorization.capabilities().to_vec(), + Epoch::new(8), + Extensions::default(), + ) + .unwrap(); + let future_view = View { + context: context(), + status: ApplicationDeviceStatus::Active, + authorization: Some(&future_authorization), + }; + assert_eq!( + verify_application_event(&event, &future_view), + Err(IdentityError::InvalidEpoch) + ); +} + +#[test] +fn application_signature_domain_and_literal_vector_are_frozen() { + // The signature was independently reproduced with Python cryptography's Ed25519 + // implementation from the same 32-byte seed and literal signing message. + let secret = SecretKey::from_bytes(&[0x31; 32]); + let authorization = fixture_authorization(&secret); + let event = signed_event(&secret, &authorization, context(), b"payload".to_vec()); + + assert_eq!( + hex::encode(event.body().signing_bytes().unwrap()), + "4b52494b4f532d49442f6170706c69636174696f6e2d6576656e742d7369676e61747572652f76310001014141414141414141414141414141414141414141414141414141414141414141014343434343434343434343434343434343434343434343434343434343434343010bab17cf309c883b316065a68c5f382609e0d3f4a7b087e5609c7deabc55d916070142424242424242424242424242424242424242424242424242424242424242420b077061796c6f616400" + ); + assert_eq!( + hex::encode(event.signature().as_bytes()), + "ccc37fc482fd062736d4601f139d0157680eac0e7b6826e9df98465d4880ec45f502fc8caa2a516f21531b754cd8b4c3164ec7d4a9cdeb8584ac3300b9c2fa02" + ); +} + +#[test] +fn context_field_substitution_fails_even_under_a_matching_substituted_view() { + let secret = SecretKey::from_bytes(&[0x31; 32]); + let authorization = fixture_authorization(&secret); + let original = signed_event(&secret, &authorization, context(), b"payload".to_vec()); + let substituted_contexts = [ + AuthorizationContext::new( + typed_id::(0x61), + context().epoch(), + context().checkpoint_id(), + ), + AuthorizationContext::new( + context().account_id(), + Epoch::new(8), + context().checkpoint_id(), + ), + AuthorizationContext::new( + context().account_id(), + context().epoch(), + typed_id::(0x62), + ), + ]; + for substituted_context in substituted_contexts { + let substituted_body = ApplicationEventBody::new( + substituted_context.account_id(), + original.body().application_id(), + original.body().device_id(), + substituted_context.epoch(), + substituted_context.checkpoint_id(), + original.body().local_counter(), + original.body().payload().to_vec(), + Extensions::default(), + ) + .unwrap(); + let substituted = + SignedApplicationEvent::new(substituted_body, original.signature()).unwrap(); + let substituted_view = View { + context: substituted_context, + status: ApplicationDeviceStatus::Active, + authorization: Some(&authorization), + }; + assert_eq!( + verify_application_event(&substituted, &substituted_view), + Err(IdentityError::InvalidSignature) + ); + } + + let other_secret = SecretKey::from_bytes(&[0x63; 32]); + let other_authorization = fixture_authorization(&other_secret); + let substituted_body = ApplicationEventBody::new( + original.body().account_id(), + original.body().application_id(), + other_authorization.device_id(), + original.body().account_epoch(), + original.body().checkpoint_id(), + original.body().local_counter(), + original.body().payload().to_vec(), + Extensions::default(), + ) + .unwrap(); + let substituted = SignedApplicationEvent::new(substituted_body, original.signature()).unwrap(); + let substituted_view = View { + context: context(), + status: ApplicationDeviceStatus::Active, + authorization: Some(&other_authorization), + }; + assert_eq!( + verify_application_event(&substituted, &substituted_view), + Err(IdentityError::InvalidSignature) + ); +} diff --git a/protocols/krikos-identity/tests/capabilities.rs b/protocols/krikos-identity/tests/capabilities.rs new file mode 100644 index 00000000000..755a47199d6 --- /dev/null +++ b/protocols/krikos-identity/tests/capabilities.rs @@ -0,0 +1,1743 @@ +use krikos_identity::{ + AccountId, ApplicationId, AuthorizationContext, CanonicalWire, CapabilityAction, + CapabilityDenialReason, CapabilityDeviceStatus, CapabilityGrant, CapabilityGrantId, + CapabilityNamespace, CapabilityProof, CapabilityRequest, CapabilityStateView, CheckpointId, + DelegationBody, DelegationChain, DelegationDepth, DelegationId, DelegationPermission, + DelegationSignatureStatus, DelegationSignatureVerifier, DeviceId, Digest, Epoch, Extensions, + HashAlgorithm, IdentityError, ProtocolSignature, ResourcePath, ResourceSelector, + SignedDelegation, Timestamp, evaluate_capability, +}; +use proptest::prelude::*; + +fn digest(seed: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [seed; 32]) +} + +fn account_id(seed: u8) -> AccountId { + AccountId::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn checkpoint_id(seed: u8) -> CheckpointId { + CheckpointId::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn device_id(seed: u8) -> DeviceId { + DeviceId::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn context(epoch: u64, checkpoint_seed: u8) -> AuthorizationContext { + AuthorizationContext::new( + account_id(1), + Epoch::new(epoch), + checkpoint_id(checkpoint_seed), + ) +} + +fn path(segments: &[&[u8]]) -> ResourcePath { + ResourcePath::new(segments.iter().map(|segment| segment.to_vec()).collect()).unwrap() +} + +fn grant( + resource: ResourceSelector, + constraints: Vec, + delegation: DelegationPermission, + expires_at: Option, +) -> CapabilityGrant { + CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + resource, + constraints, + delegation, + expires_at, + Extensions::default(), + ) + .unwrap() +} + +fn request( + authorization_context: AuthorizationContext, + device_id: DeviceId, + resource: ResourcePath, + evaluated_at: u64, +) -> CapabilityRequest { + CapabilityRequest::new( + authorization_context, + ApplicationId::new(digest(90)), + device_id, + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + resource, + Timestamp::from_unix_millis(evaluated_at), + ) +} + +#[derive(Debug)] +struct TestState { + authorization_context: AuthorizationContext, + statuses: Vec<(DeviceId, CapabilityDeviceStatus)>, + root_holder: DeviceId, + root_grants: Vec, + revoked_grants: Vec, + revoked_delegations: Vec, + recognized_contexts: Vec, + context_lineage: Vec<(AuthorizationContext, AuthorizationContext)>, + context_times: Vec<(AuthorizationContext, Timestamp)>, + historical_statuses: Vec<(DeviceId, AuthorizationContext, CapabilityDeviceStatus)>, + historical_holdings: Vec<(DeviceId, CapabilityGrantId, AuthorizationContext)>, +} + +impl TestState { + fn active( + authorization_context: AuthorizationContext, + device_id: DeviceId, + root_grants: Vec, + ) -> Self { + Self { + authorization_context, + statuses: vec![(device_id, CapabilityDeviceStatus::Active)], + root_holder: device_id, + root_grants, + revoked_grants: Vec::new(), + revoked_delegations: Vec::new(), + recognized_contexts: vec![authorization_context], + context_lineage: Vec::new(), + context_times: Vec::new(), + historical_statuses: Vec::new(), + historical_holdings: Vec::new(), + } + } + + fn record_chain_history(&mut self, chain: &DelegationChain) { + let root_context = chain.root().authorization_context(); + if !self.recognized_contexts.contains(&root_context) { + self.recognized_contexts.push(root_context); + } + let mut parent_grant_id = chain.root().grant().capability_grant_id().unwrap(); + self.context_lineage + .push((root_context, self.authorization_context)); + let root_time = chain + .links() + .first() + .map_or(Timestamp::from_unix_millis(0), |link| { + link.body().issued_at() + }); + if !self + .context_times + .iter() + .any(|(context, _)| *context == root_context) + { + self.context_times.push((root_context, root_time)); + } + self.historical_statuses.push(( + chain.root().holder(), + root_context, + CapabilityDeviceStatus::Active, + )); + self.historical_holdings + .push((chain.root().holder(), parent_grant_id, root_context)); + let mut previous_context = root_context; + for link in chain.links() { + let body = link.body(); + let context = body.authorization_context(); + if !self.recognized_contexts.contains(&context) { + self.recognized_contexts.push(context); + } + self.context_lineage.push((previous_context, context)); + self.context_lineage + .push((context, self.authorization_context)); + if !self + .context_times + .iter() + .any(|(candidate, _)| *candidate == context) + { + self.context_times.push((context, body.issued_at())); + } + self.historical_statuses + .push((body.issuer(), context, CapabilityDeviceStatus::Active)); + self.historical_holdings + .push((body.issuer(), parent_grant_id, context)); + parent_grant_id = body.child_grant().capability_grant_id().unwrap(); + previous_context = context; + } + } +} + +impl CapabilityStateView for TestState { + fn authorization_context(&self) -> AuthorizationContext { + self.authorization_context + } + + fn device_status(&self, device_id: DeviceId) -> CapabilityDeviceStatus { + self.statuses + .iter() + .find_map(|(candidate, status)| (*candidate == device_id).then_some(*status)) + .unwrap_or(CapabilityDeviceStatus::Unknown) + } + + fn root_grants(&self, holder: DeviceId) -> &[CapabilityGrant] { + if holder == self.root_holder { + &self.root_grants + } else { + &[] + } + } + + fn is_grant_revoked(&self, grant_id: CapabilityGrantId) -> bool { + self.revoked_grants.contains(&grant_id) + } + + fn is_delegation_revoked(&self, delegation_id: DelegationId) -> bool { + self.revoked_delegations.contains(&delegation_id) + } + + fn recognizes_authorization_context(&self, context: AuthorizationContext) -> bool { + self.recognized_contexts.contains(&context) + } + + fn authorization_context_precedes_or_equals( + &self, + ancestor: AuthorizationContext, + descendant: AuthorizationContext, + ) -> bool { + self.recognized_contexts.contains(&ancestor) + && self.recognized_contexts.contains(&descendant) + && ancestor.account_id() == descendant.account_id() + && (ancestor == descendant || self.context_lineage.contains(&(ancestor, descendant))) + } + + fn authorization_context_timestamp(&self, context: AuthorizationContext) -> Option { + self.context_times + .iter() + .find_map(|(candidate, timestamp)| (*candidate == context).then_some(*timestamp)) + } + + fn device_status_at( + &self, + device_id: DeviceId, + context: AuthorizationContext, + ) -> CapabilityDeviceStatus { + self.historical_statuses + .iter() + .find_map(|(candidate, candidate_context, status)| { + (*candidate == device_id && *candidate_context == context).then_some(*status) + }) + .unwrap_or(CapabilityDeviceStatus::Unknown) + } + + fn held_grant_at( + &self, + holder: DeviceId, + grant_id: CapabilityGrantId, + context: AuthorizationContext, + ) -> bool { + self.historical_holdings + .contains(&(holder, grant_id, context)) + } +} + +#[derive(Debug, Clone, Copy)] +struct TestSignatures(DelegationSignatureStatus); + +impl DelegationSignatureVerifier for TestSignatures { + fn verify_delegation(&self, _delegation: &SignedDelegation) -> DelegationSignatureStatus { + self.0 + } +} + +const VERIFIED_SIGNATURES: TestSignatures = TestSignatures(DelegationSignatureStatus::Verified); + +#[test] +fn default_deny_records_the_exact_checkpoint_and_epoch_basis() { + let device = device_id(1); + let basis = context(7, 7); + let state = TestState::active(basis, device, Vec::new()); + let request = request(basis, device, path(&[b"collection", b"blue"]), 100); + + let decision = evaluate_capability( + &request, + CapabilityProof::Direct, + &state, + &VERIFIED_SIGNATURES, + ); + + assert!(!decision.is_allowed()); + assert_eq!( + decision.denial_reason(), + Some(CapabilityDenialReason::NoMatchingGrant) + ); + assert_eq!(decision.checkpoint_id(), basis.checkpoint_id()); + assert_eq!(decision.epoch(), basis.epoch()); + assert_eq!(decision.application_id(), ApplicationId::new(digest(90))); +} + +#[test] +fn exact_and_prefix_selectors_match_only_complete_resource_segments() { + let device = device_id(2); + let basis = context(2, 2); + let exact = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let exact_id = exact.capability_grant_id().unwrap(); + let exact_state = TestState::active(basis, device, vec![exact]); + + let exact_decision = evaluate_capability( + &request(basis, device, path(&[b"collection", b"blue"]), 1), + CapabilityProof::Direct, + &exact_state, + &VERIFIED_SIGNATURES, + ); + assert!(exact_decision.is_allowed()); + assert_eq!(exact_decision.grant_id(), Some(exact_id)); + assert_eq!(exact_decision.delegation_id(), None); + + let exact_child = evaluate_capability( + &request(basis, device, path(&[b"collection", b"blue", b"record"]), 1), + CapabilityProof::Direct, + &exact_state, + &VERIFIED_SIGNATURES, + ); + assert_eq!( + exact_child.denial_reason(), + Some(CapabilityDenialReason::ResourceNotGranted) + ); + + let prefix = grant( + ResourceSelector::prefix(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let prefix_state = TestState::active(basis, device, vec![prefix]); + let prefix_child = evaluate_capability( + &request(basis, device, path(&[b"collection", b"blue", b"record"]), 1), + CapabilityProof::Direct, + &prefix_state, + &VERIFIED_SIGNATURES, + ); + assert!(prefix_child.is_allowed()); + + let partial_segment = evaluate_capability( + &request(basis, device, path(&[b"collection", b"bluebird"]), 1), + CapabilityProof::Direct, + &prefix_state, + &VERIFIED_SIGNATURES, + ); + assert_eq!( + partial_segment.denial_reason(), + Some(CapabilityDenialReason::ResourceNotGranted) + ); +} + +#[test] +fn namespace_and_action_matching_is_exact() { + let device = device_id(4); + let basis = context(2, 2); + let state = TestState::active( + basis, + device, + vec![grant( + ResourceSelector::exact(path(&[b"record"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + )], + ); + let different_namespace = CapabilityRequest::new( + basis, + ApplicationId::new(digest(90)), + device, + CapabilityNamespace::new("krikos.database.extra").unwrap(), + CapabilityAction::new("write").unwrap(), + path(&[b"record"]), + Timestamp::from_unix_millis(1), + ); + assert_eq!( + evaluate_capability( + &different_namespace, + CapabilityProof::Direct, + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::NamespaceNotGranted) + ); + + let different_action = CapabilityRequest::new( + basis, + ApplicationId::new(digest(90)), + device, + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write-all").unwrap(), + path(&[b"record"]), + Timestamp::from_unix_millis(1), + ); + assert_eq!( + evaluate_capability( + &different_action, + CapabilityProof::Direct, + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::ActionNotGranted) + ); +} + +#[test] +fn constraints_are_conjunctive_and_expiration_is_an_exclusive_bound() { + use krikos_identity::CapabilityConstraint::{ + AccountEpochAtLeast, AccountEpochAtMost, ValidFrom, + }; + + let device = device_id(3); + let constrained = grant( + ResourceSelector::exact(path(&[b"record"])).unwrap(), + vec![ + AccountEpochAtLeast(Epoch::new(2)), + AccountEpochAtMost(Epoch::new(4)), + ValidFrom(Timestamp::from_unix_millis(100)), + ], + DelegationPermission::NotDelegable, + Some(Timestamp::from_unix_millis(200)), + ); + + let valid_basis = context(3, 3); + let valid_state = TestState::active(valid_basis, device, vec![constrained.clone()]); + assert!( + evaluate_capability( + &request(valid_basis, device, path(&[b"record"]), 100), + CapabilityProof::Direct, + &valid_state, + &VERIFIED_SIGNATURES, + ) + .is_allowed() + ); + + let early_basis = context(1, 1); + let early_state = TestState::active(early_basis, device, vec![constrained.clone()]); + assert_eq!( + evaluate_capability( + &request(early_basis, device, path(&[b"record"]), 100), + CapabilityProof::Direct, + &early_state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::ConstraintUnsatisfied) + ); + + assert_eq!( + evaluate_capability( + &request(valid_basis, device, path(&[b"record"]), 99), + CapabilityProof::Direct, + &valid_state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::ConstraintUnsatisfied) + ); + assert_eq!( + evaluate_capability( + &request(valid_basis, device, path(&[b"record"]), 200), + CapabilityProof::Direct, + &valid_state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::GrantExpired) + ); +} + +#[test] +fn delegation_must_narrow_and_remains_revocable_through_every_parent() { + let root_device = device_id(10); + let leaf_device = device_id(11); + let basis = context(5, 5); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + Some(Timestamp::from_unix_millis(300)), + ); + let child_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + Some(Timestamp::from_unix_millis(250)), + ); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + basis, + Timestamp::from_unix_millis(10), + [7; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([7; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + basis, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![link.clone()], + ) + .unwrap(); + let mut state = TestState::active(basis, root_device, vec![root_grant.clone()]); + state + .statuses + .push((leaf_device, CapabilityDeviceStatus::Active)); + state.record_chain_history(&chain); + let delegated_request = request(basis, leaf_device, path(&[b"collection", b"blue"]), 100); + + let delegated_decision = evaluate_capability( + &delegated_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ); + assert!(delegated_decision.is_allowed()); + assert_eq!( + delegated_decision.delegation_id(), + Some(link.delegation_id().unwrap()) + ); + + state + .revoked_grants + .push(root_grant.capability_grant_id().unwrap()); + assert_eq!( + evaluate_capability( + &delegated_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::ParentGrantRevoked) + ); + state.revoked_grants.clear(); + state + .revoked_delegations + .push(link.delegation_id().unwrap()); + assert_eq!( + evaluate_capability( + &delegated_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::DelegationRevoked) + ); + + let broader_child = grant( + ResourceSelector::prefix(path(&[b"other"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + Some(Timestamp::from_unix_millis(250)), + ); + let broader_link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + broader_child, + root_device, + leaf_device, + basis, + Timestamp::from_unix_millis(10), + [8; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([8; 64]), + ); + assert!(matches!( + DelegationChain::new(chain.root().clone(), vec![broader_link]), + Err(IdentityError::InvalidDelegation { .. }) + )); +} + +#[test] +fn stale_checkpoint_or_epoch_is_denied_before_grant_evaluation() { + let device = device_id(20); + let current = context(4, 4); + let allowed_grant = grant( + ResourceSelector::exact(path(&[b"record"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let state = TestState::active(current, device, vec![allowed_grant]); + + let stale_epoch = request(context(3, 4), device, path(&[b"record"]), 1); + assert_eq!( + evaluate_capability( + &stale_epoch, + CapabilityProof::Direct, + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::EpochMismatch) + ); + + let stale_checkpoint = request(context(4, 3), device, path(&[b"record"]), 1); + assert_eq!( + evaluate_capability( + &stale_checkpoint, + CapabilityProof::Direct, + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::CheckpointMismatch) + ); +} + +#[test] +fn unknown_suspended_and_revoked_devices_are_default_denied() { + let device = device_id(30); + let basis = context(8, 8); + let allowed_grant = grant( + ResourceSelector::exact(path(&[b"record"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let request = request(basis, device, path(&[b"record"]), 1); + + for (status, expected) in [ + ( + CapabilityDeviceStatus::Unknown, + CapabilityDenialReason::UnknownDevice, + ), + ( + CapabilityDeviceStatus::Suspended, + CapabilityDenialReason::DeviceSuspended, + ), + ( + CapabilityDeviceStatus::Revoked, + CapabilityDenialReason::DeviceRevoked, + ), + ] { + let mut state = TestState::active(basis, device, vec![allowed_grant.clone()]); + state.statuses = vec![(device, status)]; + assert_eq!( + evaluate_capability( + &request, + CapabilityProof::Direct, + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(expected) + ); + } +} + +#[test] +fn delegation_requires_a_real_signature_verification_result() { + let root_device = device_id(40); + let leaf_device = device_id(41); + let basis = context(9, 9); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + None, + ); + let child_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + basis, + Timestamp::from_unix_millis(1), + [1; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([1; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + basis, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![link], + ) + .unwrap(); + let mut state = TestState::active(basis, root_device, vec![root_grant]); + state + .statuses + .push((leaf_device, CapabilityDeviceStatus::Active)); + state.record_chain_history(&chain); + let capability_request = request(basis, leaf_device, path(&[b"collection", b"blue"]), 10); + + for (signature_status, expected) in [ + ( + DelegationSignatureStatus::Unavailable, + CapabilityDenialReason::SignatureVerificationUnavailable, + ), + ( + DelegationSignatureStatus::Invalid, + CapabilityDenialReason::InvalidDelegationSignature, + ), + ] { + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &TestSignatures(signature_status), + ) + .denial_reason(), + Some(expected) + ); + } + + let request_before_issuance = request(basis, leaf_device, path(&[b"collection", b"blue"]), 0); + assert_eq!( + evaluate_capability( + &request_before_issuance, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::DelegationNotYetValid) + ); + + state.recognized_contexts.clear(); + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::UnrecognizedAuthorizationContext) + ); +} + +#[test] +fn delegation_requires_historical_grant_possession_and_an_active_issuer() { + let root_device = device_id(50); + let leaf_device = device_id(51); + let root_context = context(4, 4); + let basis = context(5, 5); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + None, + ); + let child_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + basis, + Timestamp::from_unix_millis(10), + [2; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([2; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + root_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![link], + ) + .unwrap(); + let root_grant_id = root_grant.capability_grant_id().unwrap(); + let capability_request = request(basis, leaf_device, path(&[b"collection", b"blue"]), 20); + let mut state = TestState::active(basis, root_device, vec![root_grant]); + state + .statuses + .push((leaf_device, CapabilityDeviceStatus::Active)); + state.recognized_contexts.push(root_context); + state.context_lineage.push((root_context, basis)); + state + .context_times + .push((root_context, Timestamp::from_unix_millis(0))); + state + .context_times + .push((basis, Timestamp::from_unix_millis(10))); + state + .historical_statuses + .push((root_device, root_context, CapabilityDeviceStatus::Active)); + state + .historical_holdings + .push((root_device, root_grant_id, root_context)); + state + .historical_statuses + .push((root_device, basis, CapabilityDeviceStatus::Active)); + + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::ParentGrantNotHeldAtIssuance) + ); + + state.historical_statuses[1].2 = CapabilityDeviceStatus::Suspended; + state + .historical_holdings + .push((root_device, root_grant_id, basis)); + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::IssuerNotActiveAtIssuance) + ); +} + +#[test] +fn delegation_rejects_a_parent_expired_at_issuance() { + let root_device = device_id(52); + let leaf_device = device_id(53); + let root_context = context(4, 4); + let basis = context(5, 5); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + Some(Timestamp::from_unix_millis(50)), + ); + let child_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + Some(Timestamp::from_unix_millis(40)), + ); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + basis, + Timestamp::from_unix_millis(60), + [3; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([3; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + root_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![link], + ) + .unwrap(); + let mut state = TestState::active(basis, root_device, vec![root_grant]); + state + .statuses + .push((leaf_device, CapabilityDeviceStatus::Active)); + state.record_chain_history(&chain); + state + .context_times + .iter_mut() + .find(|(context, _)| *context == root_context) + .unwrap() + .1 = Timestamp::from_unix_millis(10); + + assert_eq!( + evaluate_capability( + &request(basis, leaf_device, path(&[b"collection", b"blue"]), 100,), + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::ParentGrantInvalidAtIssuance) + ); +} + +#[test] +fn delegation_cannot_be_presigned_at_a_zero_step_context_before_parent_epoch() { + let root_device = device_id(54); + let leaf_device = device_id(55); + let issuance_context = context(1, 1); + let current_context = context(3, 3); + let minimum_epoch = krikos_identity::CapabilityConstraint::AccountEpochAtLeast(Epoch::new(2)); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + vec![minimum_epoch], + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + None, + ); + let child_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + vec![minimum_epoch], + DelegationPermission::NotDelegable, + None, + ); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + issuance_context, + Timestamp::from_unix_millis(10), + [6; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([6; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + issuance_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![link], + ) + .unwrap(); + let mut state = TestState::active(current_context, root_device, vec![root_grant]); + state + .statuses + .push((leaf_device, CapabilityDeviceStatus::Active)); + state.record_chain_history(&chain); + + assert_eq!( + evaluate_capability( + &request( + current_context, + leaf_device, + path(&[b"collection", b"blue"]), + 20, + ), + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::RootGrantInvalidAtContext) + ); +} + +#[test] +fn delegation_contexts_cannot_roll_back_between_links() { + let root_device = device_id(60); + let middle_device = device_id(61); + let leaf_device = device_id(62); + let root_context = context(1, 1); + let forward_context = context(3, 3); + let rollback_context = context(2, 2); + let current_context = context(4, 4); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()), + None, + ); + let middle_grant = grant( + ResourceSelector::prefix(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + None, + ); + let leaf_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue", b"record"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let first = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + middle_grant.clone(), + root_device, + middle_device, + forward_context, + Timestamp::from_unix_millis(10), + [4; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([4; 64]), + ); + let second = SignedDelegation::new( + DelegationBody::new( + middle_grant.capability_grant_id().unwrap(), + leaf_grant, + middle_device, + leaf_device, + rollback_context, + Timestamp::from_unix_millis(20), + [5; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([5; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + root_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![first, second], + ) + .unwrap(); + let mut state = TestState::active(current_context, root_device, vec![root_grant]); + state.statuses.extend([ + (middle_device, CapabilityDeviceStatus::Active), + (leaf_device, CapabilityDeviceStatus::Active), + ]); + state + .recognized_contexts + .extend([root_context, forward_context, rollback_context]); + state.record_chain_history(&chain); + + assert_eq!( + evaluate_capability( + &request( + current_context, + leaf_device, + path(&[b"collection", b"blue", b"record"]), + 30, + ), + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::AuthorizationContextRollback) + ); +} + +#[test] +fn delegated_root_requires_historical_active_possession_and_context_time_validity() { + let root_device = device_id(56); + let leaf_device = device_id(57); + let old_context = context(1, 1); + let current_context = context(3, 3); + let valid_from = + krikos_identity::CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(50)); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + vec![valid_from], + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + None, + ); + let child_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + vec![valid_from], + DelegationPermission::NotDelegable, + None, + ); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + current_context, + Timestamp::from_unix_millis(60), + [9; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([9; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + old_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![link], + ) + .unwrap(); + let root_grant_id = root_grant.capability_grant_id().unwrap(); + let mut state = TestState::active(current_context, root_device, vec![root_grant]); + state + .statuses + .push((leaf_device, CapabilityDeviceStatus::Active)); + state.recognized_contexts.push(old_context); + state.context_lineage.push((old_context, current_context)); + state + .context_times + .push((old_context, Timestamp::from_unix_millis(60))); + + let capability_request = request( + current_context, + leaf_device, + path(&[b"collection", b"blue"]), + 100, + ); + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::RootGrantNotHeldAtContext) + ); + + state + .historical_holdings + .push((root_device, root_grant_id, old_context)); + state + .historical_statuses + .push((root_device, old_context, CapabilityDeviceStatus::Suspended)); + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::RootHolderNotActiveAtContext) + ); + + state.historical_statuses[0].2 = CapabilityDeviceStatus::Active; + state.context_times[0].1 = Timestamp::from_unix_millis(40); + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::RootGrantInvalidAtContext) + ); +} + +#[test] +fn same_epoch_descendant_context_is_accepted_but_a_sibling_is_denied() { + let root_device = device_id(58); + let leaf_device = device_id(59); + let root_context = AuthorizationContext::new(account_id(1), Epoch::new(5), checkpoint_id(1)); + let descendant_context = + AuthorizationContext::new(account_id(1), Epoch::new(5), checkpoint_id(2)); + let current_context = AuthorizationContext::new(account_id(1), Epoch::new(5), checkpoint_id(3)); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + None, + ); + let child_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + ); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + descendant_context, + Timestamp::from_unix_millis(10), + [10; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([10; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + root_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![link], + ) + .unwrap(); + let mut state = TestState::active(current_context, root_device, vec![root_grant]); + state + .statuses + .push((leaf_device, CapabilityDeviceStatus::Active)); + state.record_chain_history(&chain); + let capability_request = request( + current_context, + leaf_device, + path(&[b"collection", b"blue"]), + 20, + ); + + assert!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .is_allowed() + ); + + state + .context_lineage + .retain(|pair| *pair != (root_context, descendant_context)); + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::AuthorizationContextRollback) + ); +} + +fn same_epoch_temporal_fixture( + first_issued_at: u64, + second_issued_at: u64, +) -> (DelegationChain, TestState, CapabilityRequest) { + let root_device = device_id(60); + let middle_device = device_id(61); + let leaf_device = device_id(62); + let root_context = context(5, 20); + let first_context = context(5, 21); + let second_context = context(5, 22); + let current_context = context(5, 23); + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()), + Some(Timestamp::from_unix_millis(200)), + ); + let middle_grant = grant( + ResourceSelector::prefix(path(&[b"collection", b"blue"])).unwrap(), + Vec::new(), + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + Some(Timestamp::from_unix_millis(190)), + ); + let leaf_grant = grant( + ResourceSelector::exact(path(&[b"collection", b"blue", b"record"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + Some(Timestamp::from_unix_millis(180)), + ); + let first = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + middle_grant.clone(), + root_device, + middle_device, + first_context, + Timestamp::from_unix_millis(first_issued_at), + [11; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([11; 64]), + ); + let second = SignedDelegation::new( + DelegationBody::new( + middle_grant.capability_grant_id().unwrap(), + leaf_grant, + middle_device, + leaf_device, + second_context, + Timestamp::from_unix_millis(second_issued_at), + [12; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([12; 64]), + ); + let chain = DelegationChain::new( + krikos_identity::CapabilityRoot::new( + root_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(), + vec![first, second], + ) + .unwrap(); + let mut state = TestState::active(current_context, root_device, vec![root_grant]); + state.statuses.extend([ + (middle_device, CapabilityDeviceStatus::Active), + (leaf_device, CapabilityDeviceStatus::Active), + ]); + state.record_chain_history(&chain); + let capability_request = request( + current_context, + leaf_device, + path(&[b"collection", b"blue", b"record"]), + 100, + ); + (chain, state, capability_request) +} + +#[test] +fn delegation_requires_an_authenticated_timestamp_for_every_link_context() { + for missing_index in 0..2 { + let (chain, mut state, capability_request) = same_epoch_temporal_fixture(10, 20); + assert!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .is_allowed(), + "same-epoch temporal fixture must be valid before fault injection" + ); + let missing_context = chain.links()[missing_index].body().authorization_context(); + state + .context_times + .retain(|(context, _)| *context != missing_context); + + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::DelegationContextTimestampUnavailable), + "delegation link {missing_index} did not fail with the typed missing-time reason" + ); + } +} + +#[test] +fn same_epoch_context_time_cannot_postdate_claimed_issuance() { + let (chain, mut state, capability_request) = same_epoch_temporal_fixture(10, 20); + let first_context = chain.links()[0].body().authorization_context(); + state + .context_times + .iter_mut() + .find(|(context, _)| *context == first_context) + .unwrap() + .1 = Timestamp::from_unix_millis(11); + + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::DelegationIssuedBeforeContext) + ); +} + +#[test] +fn same_epoch_delegation_issuance_times_cannot_roll_back() { + let (chain, state, capability_request) = same_epoch_temporal_fixture(20, 19); + + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::DelegationIssuanceRollback) + ); +} + +#[test] +fn root_context_time_cannot_postdate_first_delegation_issuance() { + let (chain, mut state, capability_request) = same_epoch_temporal_fixture(10, 20); + let root_context = chain.root().authorization_context(); + state + .context_times + .iter_mut() + .find(|(context, _)| *context == root_context) + .unwrap() + .1 = Timestamp::from_unix_millis(11); + + assert_eq!( + evaluate_capability( + &capability_request, + CapabilityProof::Delegated(&chain), + &state, + &VERIFIED_SIGNATURES, + ) + .denial_reason(), + Some(CapabilityDenialReason::DelegationIssuanceRollback) + ); +} + +#[derive(Debug)] +struct MultiHopFixture { + chain: DelegationChain, + state: TestState, + request: CapabilityRequest, + grant_ids: Vec, + delegation_ids: Vec, +} + +fn multi_hop_fixture(context_steps: &[u8]) -> MultiHopFixture { + assert!( + (2..=4).contains(&context_steps.len()), + "property fixture delegation depth must remain in 2..=4" + ); + assert!( + context_steps.iter().all(|step| (1..=2).contains(step)), + "property fixture context steps must remain in 1..=2" + ); + let link_count = context_steps.len(); + let root_device = device_id(70); + let root_context = context(1, 1); + let mut resource_segments = vec![b"collection".to_vec()]; + let root_depth = u8::try_from(link_count).unwrap(); + let root_grant = CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + ResourceSelector::prefix(ResourcePath::new(resource_segments.clone()).unwrap()).unwrap(), + vec![ + krikos_identity::CapabilityConstraint::AccountEpochAtLeast(Epoch::new(1)), + krikos_identity::CapabilityConstraint::AccountEpochAtMost(Epoch::new(100)), + krikos_identity::CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(1)), + ], + DelegationPermission::delegable(DelegationDepth::new(root_depth).unwrap()), + Some(Timestamp::from_unix_millis(1_000)), + Extensions::default(), + ) + .unwrap(); + let root = krikos_identity::CapabilityRoot::new( + root_context, + root_device, + root_grant.clone(), + Extensions::default(), + ) + .unwrap(); + + let mut links = Vec::with_capacity(link_count); + let mut grant_ids = vec![root_grant.capability_grant_id().unwrap()]; + let mut delegation_ids = Vec::with_capacity(link_count); + let mut parent_grant = root_grant.clone(); + let mut issuer = root_device; + let mut previous_context = root_context; + + for (index, context_step) in context_steps.iter().copied().enumerate() { + let ordinal = u64::try_from(index).unwrap().checked_add(1).unwrap(); + resource_segments.push(vec![u8::try_from(index).unwrap()]); + let links_remaining = link_count + .checked_sub(index) + .unwrap() + .checked_sub(1) + .unwrap(); + let delegation = if links_remaining == 0 { + DelegationPermission::NotDelegable + } else { + DelegationPermission::delegable( + DelegationDepth::new(u8::try_from(links_remaining).unwrap()).unwrap(), + ) + }; + let selector = if links_remaining == 0 { + ResourceSelector::exact(ResourcePath::new(resource_segments.clone()).unwrap()).unwrap() + } else { + ResourceSelector::prefix(ResourcePath::new(resource_segments.clone()).unwrap()).unwrap() + }; + let child_grant = CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + selector, + vec![ + krikos_identity::CapabilityConstraint::AccountEpochAtLeast(Epoch::new( + 1_u64.checked_add(ordinal).unwrap(), + )), + krikos_identity::CapabilityConstraint::AccountEpochAtMost(Epoch::new( + 100_u64.checked_sub(ordinal).unwrap(), + )), + krikos_identity::CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis( + 1_u64.checked_add(ordinal).unwrap(), + )), + ], + delegation, + Some(Timestamp::from_unix_millis( + 1_000_u64 + .checked_sub(ordinal.checked_mul(10).unwrap()) + .unwrap(), + )), + Extensions::default(), + ) + .unwrap(); + let next_epoch = Epoch::new( + previous_context + .epoch() + .get() + .checked_add(u64::from(context_step)) + .unwrap(), + ); + let issuance_context = AuthorizationContext::new( + account_id(1), + next_epoch, + checkpoint_id(u8::try_from(next_epoch.get()).unwrap()), + ); + let subject = device_id( + u8::try_from(70_usize.checked_add(index).unwrap().checked_add(1).unwrap()).unwrap(), + ); + let body = DelegationBody::new( + parent_grant.capability_grant_id().unwrap(), + child_grant.clone(), + issuer, + subject, + issuance_context, + Timestamp::from_unix_millis(100_u64.checked_add(ordinal).unwrap()), + [u8::try_from(index).unwrap(); 16], + Extensions::default(), + ) + .unwrap(); + let link = SignedDelegation::new( + body, + ProtocolSignature::ed25519([u8::try_from(index).unwrap(); 64]), + ); + grant_ids.push(child_grant.capability_grant_id().unwrap()); + delegation_ids.push(link.delegation_id().unwrap()); + links.push(link); + parent_grant = child_grant; + issuer = subject; + previous_context = issuance_context; + } + + let chain = DelegationChain::new(root, links).unwrap(); + let current_epoch = previous_context.epoch().checked_next().unwrap(); + let current_context = AuthorizationContext::new( + account_id(1), + current_epoch, + checkpoint_id(u8::try_from(current_epoch.get()).unwrap()), + ); + let leaf_device = chain.leaf_holder(); + let mut state = TestState::active(current_context, root_device, vec![root_grant]); + for seed in 1..=link_count { + state.statuses.push(( + device_id(u8::try_from(70_usize.checked_add(seed).unwrap()).unwrap()), + CapabilityDeviceStatus::Active, + )); + } + state.record_chain_history(&chain); + let request = request( + current_context, + leaf_device, + ResourcePath::new(resource_segments).unwrap(), + 500, + ); + + MultiHopFixture { + chain, + state, + request, + grant_ids, + delegation_ids, + } +} + +fn broadened_chain(dimension: u8) -> Result { + let basis = context(6, 6); + let root_device = device_id(80); + let leaf_device = device_id(81); + let root_grant = CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + ResourceSelector::prefix(path(&[b"collection", b"blue"])).unwrap(), + vec![ + krikos_identity::CapabilityConstraint::AccountEpochAtLeast(Epoch::new(5)), + krikos_identity::CapabilityConstraint::AccountEpochAtMost(Epoch::new(10)), + krikos_identity::CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(50)), + ], + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()), + Some(Timestamp::from_unix_millis(200)), + Extensions::default(), + ) + .unwrap(); + + let namespace = if dimension == 0 { + CapabilityNamespace::new("krikos.other").unwrap() + } else { + CapabilityNamespace::new("krikos.database").unwrap() + }; + let action = if dimension == 1 { + CapabilityAction::new("read").unwrap() + } else { + CapabilityAction::new("write").unwrap() + }; + let selector = if dimension == 2 { + ResourceSelector::prefix(path(&[b"collection"])).unwrap() + } else { + ResourceSelector::exact(path(&[b"collection", b"blue", b"record"])).unwrap() + }; + let minimum_epoch = if dimension == 3 { 4 } else { 6 }; + let maximum_epoch = if dimension == 4 { 11 } else { 9 }; + let valid_from = if dimension == 5 { 40 } else { 60 }; + let expiration = if dimension == 6 { 210 } else { 190 }; + let delegation = if dimension == 7 { + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()) + } else { + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()) + }; + let child_grant = CapabilityGrant::new( + namespace, + action, + selector, + vec![ + krikos_identity::CapabilityConstraint::AccountEpochAtLeast(Epoch::new(minimum_epoch)), + krikos_identity::CapabilityConstraint::AccountEpochAtMost(Epoch::new(maximum_epoch)), + krikos_identity::CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis( + valid_from, + )), + ], + delegation, + Some(Timestamp::from_unix_millis(expiration)), + Extensions::default(), + ) + .unwrap(); + let link = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_grant, + root_device, + leaf_device, + basis, + Timestamp::from_unix_millis(100), + [dimension; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([dimension; 64]), + ); + DelegationChain::new( + krikos_identity::CapabilityRoot::new(basis, root_device, root_grant, Extensions::default()) + .unwrap(), + vec![link], + ) +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + failure_persistence: None, + ..ProptestConfig::default() + })] + + #[test] + fn multi_hop_all_dimension_narrowing_with_monotonic_contexts_is_authorized( + context_steps in prop::collection::vec(1_u8..=2, 2..5), + ) { + let fixture = multi_hop_fixture(&context_steps); + let decision = evaluate_capability( + &fixture.request, + CapabilityProof::Delegated(&fixture.chain), + &fixture.state, + &VERIFIED_SIGNATURES, + ); + prop_assert!(decision.is_allowed()); + } + + #[test] + fn broadening_any_capability_dimension_is_rejected(dimension in 0_u8..8) { + let rejected = matches!( + broadened_chain(dimension), + Err(IdentityError::InvalidDelegation { .. }) + ); + prop_assert!(rejected, "broadening dimension {dimension} was accepted"); + } + + #[test] + fn revoking_any_grant_or_link_in_a_multi_hop_chain_denies( + context_steps in prop::collection::vec(1_u8..=2, 2..5), + selector in any::(), + ) { + let mut fixture = multi_hop_fixture(&context_steps); + let revocable_count = fixture + .grant_ids + .len() + .checked_add(fixture.delegation_ids.len()) + .unwrap(); + let selected = usize::from(selector) % revocable_count; + if let Some(grant_id) = fixture.grant_ids.get(selected) { + fixture.state.revoked_grants.push(*grant_id); + } else { + let link_index = selected.checked_sub(fixture.grant_ids.len()).unwrap(); + fixture + .state + .revoked_delegations + .push(*fixture.delegation_ids.get(link_index).unwrap()); + } + + let decision = evaluate_capability( + &fixture.request, + CapabilityProof::Delegated(&fixture.chain), + &fixture.state, + &VERIFIED_SIGNATURES, + ); + prop_assert!(!decision.is_allowed()); + prop_assert!(matches!( + decision.denial_reason(), + Some( + CapabilityDenialReason::GrantRevoked + | CapabilityDenialReason::ParentGrantRevoked + | CapabilityDenialReason::DelegationRevoked + ) + )); + } +} diff --git a/protocols/krikos-identity/tests/capability_schema.rs b/protocols/krikos-identity/tests/capability_schema.rs new file mode 100644 index 00000000000..53b632fb49a --- /dev/null +++ b/protocols/krikos-identity/tests/capability_schema.rs @@ -0,0 +1,448 @@ +use krikos_identity::{ + AccountId, AuthorizationContext, CanonicalWire, CapabilityAction, CapabilityConstraint, + CapabilityGrant, CapabilityGrantId, CapabilityNamespace, CapabilityRoot, CheckpointId, + DelegationBody, DelegationChain, DelegationDepth, DelegationPermission, DeviceId, Digest, + Epoch, Extensions, HashAlgorithm, IdentityError, ProtocolSignature, ResourcePath, + ResourceSelector, SignedDelegation, Timestamp, + limits::{ + MAX_CAPABILITY_NAME_BYTES, MAX_CONSTRAINTS_PER_CAPABILITY, MAX_DELEGATION_DEPTH, + MAX_RESOURCE_SELECTOR_BYTES, + }, +}; + +fn digest(seed: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [seed; 32]) +} + +fn account_id(seed: u8) -> AccountId { + AccountId::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn checkpoint_id(seed: u8) -> CheckpointId { + CheckpointId::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn device_id(seed: u8) -> DeviceId { + DeviceId::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn capability_grant_id(seed: u8) -> CapabilityGrantId { + CapabilityGrantId::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn context(account_seed: u8, epoch: u64) -> AuthorizationContext { + AuthorizationContext::new( + account_id(account_seed), + Epoch::new(epoch), + checkpoint_id(epoch.to_le_bytes()[0]), + ) +} + +fn path(segments: &[&[u8]]) -> ResourcePath { + ResourcePath::new(segments.iter().map(|segment| segment.to_vec()).collect()).unwrap() +} + +fn grant( + resource: ResourceSelector, + constraints: Vec, + delegation: DelegationPermission, + expires_at: Option, +) -> CapabilityGrant { + CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + resource, + constraints, + delegation, + expires_at, + Extensions::default(), + ) + .unwrap() +} + +#[test] +fn capability_grant_golden_bytes_and_id_are_stable() { + let grant = CapabilityGrant::new( + CapabilityNamespace::new("krikos.game").unwrap(), + CapabilityAction::new("sign-move").unwrap(), + ResourceSelector::exact(path(&[b"match", b"42"])).unwrap(), + vec![ + CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(100)), + CapabilityConstraint::AccountEpochAtLeast(Epoch::new(3)), + ], + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()), + Some(Timestamp::from_unix_millis(200)), + Extensions::default(), + ) + .unwrap(); + + let expected = hex::decode(concat!( + "01", + "0b6b72696b6f732e67616d65", + "097369676e2d6d6f7665", + "0102056d61746368023432", + "0201030364", + "0202", + "01c801", + "00", + )) + .unwrap(); + assert_eq!(grant.to_canonical_bytes().unwrap(), expected); + assert_eq!( + CapabilityGrant::from_canonical_bytes(&expected).unwrap(), + grant + ); + + let grant_id = grant.capability_grant_id().unwrap(); + assert_eq!( + grant_id.to_string(), + "b3:3cc2b6caf0c765c8deb1a749978953ebb630fd8d5423e6dcc3408113ef87d19e" + ); +} + +#[test] +fn delegation_body_golden_bytes_and_id_are_stable() { + let child_grant = CapabilityGrant::new( + CapabilityNamespace::new("n").unwrap(), + CapabilityAction::new("a").unwrap(), + ResourceSelector::exact(path(&[b"x"])).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + Extensions::default(), + ) + .unwrap(); + let body = DelegationBody::new( + capability_grant_id(9), + child_grant, + device_id(1), + device_id(2), + context(3, 5), + Timestamp::from_unix_millis(7), + [8; 16], + Extensions::default(), + ) + .unwrap(); + + let mut expected = vec![1, 1]; + expected.extend_from_slice(&[9; 32]); + expected.extend_from_slice(&hex::decode("01016e0161010101780001000000").unwrap()); + expected.push(1); + expected.extend_from_slice(&[1; 32]); + expected.push(1); + expected.extend_from_slice(&[2; 32]); + expected.push(1); + expected.extend_from_slice(&[3; 32]); + expected.push(5); + expected.push(1); + expected.extend_from_slice(&[5; 32]); + expected.push(7); + expected.extend_from_slice(&[8; 16]); + expected.push(0); + + assert_eq!(body.to_canonical_bytes().unwrap(), expected); + assert_eq!( + DelegationBody::from_canonical_bytes(&expected).unwrap(), + body + ); + assert_eq!( + body.delegation_id().unwrap().to_string(), + "b3:466ccccfebb378badd1f067864b859439d9a7b6a44ed7455f0f8b27915466b45" + ); +} + +#[test] +fn names_paths_and_constraints_enforce_closed_bounds() { + assert!(matches!( + CapabilityNamespace::new(""), + Err(IdentityError::EmptyCollection { .. }) + )); + assert!(matches!( + CapabilityAction::new("x".repeat(MAX_CAPABILITY_NAME_BYTES + 1)), + Err(IdentityError::LimitExceeded { .. }) + )); + assert!(CapabilityAction::new("é".repeat(MAX_CAPABILITY_NAME_BYTES / 2)).is_ok()); + assert!( + CapabilityAction::new(format!("{}a", "é".repeat(MAX_CAPABILITY_NAME_BYTES / 2))).is_err() + ); + + assert!(matches!( + ResourcePath::new(Vec::new()), + Err(IdentityError::EmptyCollection { .. }) + )); + assert!(matches!( + ResourcePath::new(vec![Vec::new()]), + Err(IdentityError::EmptyCollection { .. }) + )); + assert!(ResourcePath::new(vec![vec![7]; 64]).is_ok()); + assert!(matches!( + ResourcePath::new(vec![vec![7]; 65]), + Err(IdentityError::LimitExceeded { .. }) + )); + assert!(matches!( + ResourcePath::new(vec![vec![7; MAX_RESOURCE_SELECTOR_BYTES]]), + Err(IdentityError::LimitExceeded { .. }) + )); + + // Constructing a contradictory range is rejected before a grant exists. + assert!(matches!( + CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + vec![ + CapabilityConstraint::AccountEpochAtLeast(Epoch::new(9)), + CapabilityConstraint::AccountEpochAtMost(Epoch::new(8)), + ], + DelegationPermission::NotDelegable, + None, + Extensions::default(), + ), + Err(IdentityError::InvalidCapability { .. }) + )); + + let duplicate_constraints = vec![ + CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(1)); + MAX_CONSTRAINTS_PER_CAPABILITY.min(2) + ]; + assert!(matches!( + CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + ResourceSelector::exact(path(&[b"record"])).unwrap(), + duplicate_constraints, + DelegationPermission::NotDelegable, + None, + Extensions::default(), + ), + Err(IdentityError::DuplicateElement { .. }) + )); + + let excessive_constraints = vec![ + CapabilityConstraint::ValidFrom(Timestamp::from_unix_millis(1)); + MAX_CONSTRAINTS_PER_CAPABILITY + 1 + ]; + assert!(matches!( + CapabilityGrant::new( + CapabilityNamespace::new("krikos.database").unwrap(), + CapabilityAction::new("write").unwrap(), + ResourceSelector::exact(path(&[b"record"])).unwrap(), + excessive_constraints, + DelegationPermission::NotDelegable, + None, + Extensions::default(), + ), + Err(IdentityError::LimitExceeded { .. }) + )); +} + +#[test] +fn closed_tags_and_noncanonical_wire_forms_are_rejected() { + assert!(CapabilityConstraint::from_canonical_bytes(&[4, 0]).is_err()); + assert!(DelegationPermission::from_canonical_bytes(&[1, 1]).is_err()); + assert!(ResourceSelector::from_canonical_bytes(&[3, 1, 1, b'x']).is_err()); + + let mut oversized_name = vec![0x81, 0x01]; + oversized_name.extend_from_slice(&[b'a'; MAX_CAPABILITY_NAME_BYTES + 1]); + assert!(CapabilityAction::from_canonical_bytes(&oversized_name).is_err()); + assert!(CapabilityNamespace::from_canonical_bytes(&[1, 0xff]).is_err()); + + // Exact selector, one path segment, declared segment length 1025. The bounded + // segment visitor rejects the declared size before attempting to allocate it. + assert!(ResourceSelector::from_canonical_bytes(&[1, 1, 0x81, 0x08]).is_err()); + + // Constraint code 3 precedes code 1. Constructors sort, but decoders reject + // wire input that is not already in canonical registry-code order. + let unsorted_grant = hex::decode("01016e016101010178020301010101000000").unwrap(); + assert!(CapabilityGrant::from_canonical_bytes(&unsorted_grant).is_err()); +} + +#[test] +fn delegation_chain_accepts_only_semantic_strict_narrowing() { + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(1))], + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()), + Some(Timestamp::from_unix_millis(300)), + ); + let root = CapabilityRoot::new( + context(1, 1), + device_id(10), + root_grant.clone(), + Extensions::default(), + ) + .unwrap(); + + let child_one = grant( + ResourceSelector::prefix(path(&[b"collection", b"blue"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(2))], + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + Some(Timestamp::from_unix_millis(250)), + ); + let first_body = DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child_one.clone(), + device_id(10), + device_id(11), + context(1, 2), + Timestamp::from_unix_millis(20), + [1; 16], + Extensions::default(), + ) + .unwrap(); + let first = SignedDelegation::new(first_body, ProtocolSignature::ed25519([1; 64])); + + let child_two = grant( + ResourceSelector::exact(path(&[b"collection", b"blue", b"record-7"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(2))], + DelegationPermission::NotDelegable, + Some(Timestamp::from_unix_millis(250)), + ); + let second_body = DelegationBody::new( + child_one.capability_grant_id().unwrap(), + child_two.clone(), + device_id(11), + device_id(12), + context(1, 2), + Timestamp::from_unix_millis(21), + [2; 16], + Extensions::default(), + ) + .unwrap(); + let second = SignedDelegation::new(second_body, ProtocolSignature::ed25519([2; 64])); + + assert!(matches!( + DelegationChain::new(root.clone(), vec![second.clone(), first.clone()]), + Err(IdentityError::InvalidDelegation { .. }) + )); + + let cycle = SignedDelegation::new( + DelegationBody::new( + child_one.capability_grant_id().unwrap(), + child_two.clone(), + device_id(11), + device_id(10), + context(1, 2), + Timestamp::from_unix_millis(21), + [9; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([9; 64]), + ); + assert!(matches!( + DelegationChain::new(root.clone(), vec![first.clone(), cycle]), + Err(IdentityError::InvalidDelegation { .. }) + )); + + let chain = DelegationChain::new(root, vec![first, second]).unwrap(); + assert_eq!(chain.links().len(), 2); + assert_eq!(chain.leaf_grant(), &child_two); + let encoded = chain.to_canonical_bytes().unwrap(); + assert_eq!( + DelegationChain::from_canonical_bytes(&encoded).unwrap(), + chain + ); +} + +#[test] +fn delegation_chain_rejects_broadening_wrong_order_and_cross_account_links() { + let root_grant = grant( + ResourceSelector::prefix(path(&[b"collection", b"blue"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(4))], + DelegationPermission::delegable(DelegationDepth::new(2).unwrap()), + Some(Timestamp::from_unix_millis(200)), + ); + let root = CapabilityRoot::new( + context(1, 4), + device_id(1), + root_grant.clone(), + Extensions::default(), + ) + .unwrap(); + + let broader = grant( + ResourceSelector::prefix(path(&[b"collection"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(3))], + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + Some(Timestamp::from_unix_millis(201)), + ); + let broadening = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + broader, + device_id(1), + device_id(2), + context(1, 4), + Timestamp::from_unix_millis(10), + [3; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([3; 64]), + ); + assert!(matches!( + DelegationChain::new(root.clone(), vec![broadening]), + Err(IdentityError::InvalidDelegation { .. }) + )); + + let narrowed = grant( + ResourceSelector::exact(path(&[b"collection", b"blue", b"record"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(5))], + DelegationPermission::delegable(DelegationDepth::new(1).unwrap()), + Some(Timestamp::from_unix_millis(190)), + ); + let cross_account = SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + narrowed, + device_id(1), + device_id(2), + context(9, 5), + Timestamp::from_unix_millis(10), + [4; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([4; 64]), + ); + assert!(matches!( + DelegationChain::new(root.clone(), vec![cross_account]), + Err(IdentityError::InvalidDelegation { .. }) + )); + + assert!(matches!( + DelegationChain::new(root.clone(), Vec::new()), + Err(IdentityError::EmptyCollection { .. }) + )); + + let repeated = (0..=MAX_DELEGATION_DEPTH) + .map(|index| { + let index_byte = index.to_le_bytes()[0]; + let child = grant( + ResourceSelector::exact(path(&[b"collection", b"blue"])).unwrap(), + vec![CapabilityConstraint::AccountEpochAtLeast(Epoch::new(5))], + DelegationPermission::NotDelegable, + Some(Timestamp::from_unix_millis(190)), + ); + SignedDelegation::new( + DelegationBody::new( + root_grant.capability_grant_id().unwrap(), + child, + device_id(1), + device_id(index_byte.saturating_add(20)), + context(1, 5), + Timestamp::from_unix_millis(10), + [index_byte; 16], + Extensions::default(), + ) + .unwrap(), + ProtocolSignature::ed25519([index_byte; 64]), + ) + }) + .collect(); + assert!(matches!( + DelegationChain::new(root, repeated), + Err(IdentityError::LimitExceeded { .. }) + )); +} diff --git a/protocols/krikos-identity/tests/checkpoint_projection.rs b/protocols/krikos-identity/tests/checkpoint_projection.rs new file mode 100644 index 00000000000..88ca2b16e40 --- /dev/null +++ b/protocols/krikos-identity/tests/checkpoint_projection.rs @@ -0,0 +1,1574 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountLifecycle, AccountOperation, AccountState, AdmissionEvidence, + AgreementPublicKey, AlgorithmSignature, CHECKPOINT_AUTHORIZED_DEVICE_TYPE_TAG, + CHECKPOINT_REVOKED_DEVICE_TYPE_TAG, CanonicalWire, CheckpointAuthorization, CheckpointBody, + CheckpointId, ControlPolicy, ControllerApprovalBody, ControllerApprovals, ControllerClass, + ControllerDescriptor, ControllerKeyId, ControllerScope, ControllerSelector, + ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, DelayEvidence, + DeviceAuthorization, DeviceClass, DeviceDescriptor, Digest, DurationMillis, EndpointPublicKey, + EventBody, EventPredecessors, Extensions, ForkCommonAncestor, ForkDescriptor, + FreshnessEvidence, FreshnessRequirement, HashAlgorithm, IdentityError, InclusionReceipt, + KeyedSignature, MemoryTransparencyLog, OperationKind, PolicyRule, ProtocolSignature, + ProtocolVersion, ProviderDescriptor, ProviderFreshness, ProviderHeadBody, ProviderHeadSigner, + ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, ProviderPolicy, + ProviderPolicyVersion, ProviderQuorum, ProviderReceipts, RecoveryAuthority, RecoveryPolicy, + RecoveryPolicyVersion, RequiredWeight, ResolveFork, RetireAccount, RevokeDevice, Sequence, + SignedCheckpoint, SignedControllerApproval, SignedProviderHead, SigningPublicKey, Timestamp, + bootstrap_checkpoint_from_genesis, bootstrap_checkpoint_from_prior, build_checkpoint_body, + build_checkpoint_merkle_sets, build_provider_checkpoint_bundle_from_genesis, + merkle::{MerkleSetKey, empty_merkle_root}, + verify_checkpoint, +}; +#[cfg(feature = "provider-store")] +use krikos_identity::{ + ProviderAdmissionControl, ProviderAdmissionRequest, RedbProviderStore, + authorize_provider_append, +}; + +struct TestProviderSigner(SecretKey); + +impl ProviderHeadSigner for TestProviderSigner { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +#[cfg(feature = "provider-store")] +struct AllowProviderAdmission; + +#[cfg(feature = "provider-store")] +impl ProviderAdmissionControl for AllowProviderAdmission { + fn check( + &self, + _admission: krikos_identity::ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn device_descriptor( + application_secret: &SecretKey, + endpoint_secret: &SecretKey, +) -> DeviceDescriptor { + DeviceDescriptor::new( + SigningPublicKey::ed25519(*application_secret.public().as_bytes()).unwrap(), + AgreementPublicKey::x25519([0x33; 32]).unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*endpoint_secret.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap() +} + +fn fixture() -> (AccountGenesis, AccountState, SecretKey) { + fixture_with_provider( + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + ) +} + +fn fixture_with_provider( + provider_policy: ProviderPolicy, +) -> (AccountGenesis, AccountState, SecretKey) { + let secret = SecretKey::from_bytes(&[0x11; 32]); + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::RetireAccount, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::AuthorizeDevice, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::RevokeDevice, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::ChangeProviderPolicy, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::ResolveFork, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x12; 32], + Timestamp::from_unix_millis(1), + policy, + vec![controller(&secret)], + recovery, + provider_policy, + Extensions::default(), + ) + .unwrap(); + let state = AccountState::from_genesis(&genesis).unwrap(); + (genesis, state, secret) +} + +fn event( + state: &AccountState, + signer: &SecretKey, + added_seed: u8, + nonce: u8, +) -> krikos_identity::AuthorizedEvent { + authorized_operation( + state, + signer, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[added_seed; 32]))), + nonce, + ) +} + +fn authorized_operation( + state: &AccountState, + signer: &SecretKey, + operation: AccountOperation, + nonce: u8, +) -> krikos_identity::AuthorizedEvent { + let resulting_epoch = state.expected_epoch_for(&operation).unwrap(); + authorized_operation_at_epoch(state, signer, operation, resulting_epoch, nonce) +} + +fn authorized_operation_at_epoch( + state: &AccountState, + signer: &SecretKey, + operation: AccountOperation, + resulting_epoch: krikos_identity::Epoch, + nonce: u8, +) -> krikos_identity::AuthorizedEvent { + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + resulting_epoch, + predecessors, + operation, + Timestamp::from_unix_millis(2), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x21); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + evidence.event_id_for_body(&body).unwrap(), + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn signed_checkpoint( + state: &AccountState, + signer: &SecretKey, + body: CheckpointBody, +) -> SignedCheckpoint { + let checkpoint_id = body.checkpoint_id().unwrap(); + let approval = checkpoint_approval(state, signer, checkpoint_id); + SignedCheckpoint::new( + body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap() +} + +fn checkpoint_approval( + state: &AccountState, + signer: &SecretKey, + checkpoint_id: CheckpointId, +) -> SignedControllerApproval { + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = + ControllerApprovalBody::checkpoint(controller_id, checkpoint_id, Extensions::default()) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap() +} + +fn checkpoint_receipt( + provider_secret: &SecretKey, + provider: &ProviderDescriptor, + account_id: krikos_identity::AccountId, + checkpoint_id: CheckpointId, + valid_signature: bool, +) -> InclusionReceipt { + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + typed_id::(0xc1), + account_id, + ProviderLogSubject::Checkpoint(checkpoint_id), + Timestamp::from_unix_millis(10), + Extensions::default(), + ) + .unwrap(); + let head = ProviderHeadBody::new( + provider.id().unwrap(), + entry.log_id(), + ProviderKeyVersion::GENESIS, + 1, + entry.merkle_leaf_hash().unwrap(), + Timestamp::from_unix_millis(50), + Extensions::default(), + ) + .unwrap(); + let signature = if valid_signature { + ProtocolSignature::ed25519( + provider_secret + .sign(&head.signing_bytes().unwrap()) + .to_bytes(), + ) + } else { + ProtocolSignature::ed25519([0; 64]) + }; + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(head, signature), + ) + .unwrap() +} + +#[test] +fn checkpoint_roots_are_deterministic_complete_and_directly_verifiable() { + let (genesis, mut state, signer) = fixture(); + let applied = event(&state, &signer, 0x13, 1); + state.validate_and_apply(&applied).unwrap(); + let issued_at = Timestamp::from_unix_millis(99); + let body = build_checkpoint_body(&state, issued_at).unwrap(); + assert_eq!(body.account_id(), state.account_id()); + assert_eq!(body.account_epoch(), state.epoch()); + assert_eq!(body.sequence(), state.sequence()); + assert_eq!(body.event_head(), state.heads()[0]); + assert_eq!(build_checkpoint_body(&state, issued_at).unwrap(), body); + assert_eq!( + body.state_root().to_string(), + "b3:1fa3cb4786e6ef78ad5bd2fd7bfbc29c0a7b949bb3a42c5067c518df6c0e484f" + ); + assert_eq!( + body.authorized_set_root().to_string(), + "b3:ac852bf31ef19b5d18fd8df40dcb4f07a8ea8066ca4094464f431618ebf339b7" + ); + assert_eq!(body.revoked_set_root(), body.authorized_set_root()); + assert_eq!( + body.crypto_state_id().to_string(), + "b3:099052dac8de6b8b96a007c10c91e7a0e97c1f210e0b833a0cc4fab90aaae645" + ); + + let checkpoint = signed_checkpoint(&state, &signer, body); + let verified = verify_checkpoint(&state, &checkpoint, None).unwrap(); + assert_eq!( + verified.checkpoint_id(), + checkpoint.checkpoint_id().unwrap() + ); + let provider_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &checkpoint, + None, + ) + .unwrap(); + assert_eq!( + provider_bundle.provider_log_admission().account_id(), + state.account_id() + ); + + let evidence = FreshnessEvidence::local_known(verified.checkpoint_id()); + let trusted = bootstrap_checkpoint_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &checkpoint, + None, + &evidence, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(100), + &[], + ) + .unwrap(); + assert_eq!(trusted.state(), &state); + assert_eq!( + trusted.checkpoint().checkpoint_id(), + verified.checkpoint_id() + ); + assert_eq!( + trusted.freshness().context().checkpoint_id(), + verified.checkpoint_id() + ); + assert!( + bootstrap_checkpoint_from_genesis( + &genesis, + &[], + &checkpoint, + None, + &evidence, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(100), + &[], + ) + .is_err() + ); + assert_eq!( + bootstrap_checkpoint_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &checkpoint, + None, + &evidence, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(100), + &[applied.event_id().unwrap()], + ), + Err(IdentityError::AccountForked) + ); + + let body = checkpoint.body(); + let substituted = CheckpointBody::new( + body.account_id(), + body.account_epoch(), + body.sequence(), + body.event_head(), + Digest::new(HashAlgorithm::Blake3_256, [0x99; 32]), + body.authorized_set_root(), + body.revoked_set_root(), + body.control_policy_id(), + body.recovery_policy_id(), + body.provider_policy_id(), + body.crypto_state_id(), + body.lifecycle(), + body.issued_at(), + Extensions::default(), + ) + .unwrap(); + let forged = signed_checkpoint(&state, &signer, substituted); + assert_eq!( + verify_checkpoint(&state, &forged, None), + Err(IdentityError::InvalidProof) + ); +} + +#[test] +fn checkpoint_build_rejects_genesis_and_unresolved_fork() { + let (_, base, signer) = fixture(); + assert!(matches!( + build_checkpoint_body(&base, Timestamp::from_unix_millis(3)), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let left = event(&base, &signer, 0x14, 2); + let right = event(&base, &signer, 0x15, 3); + let mut forked = base; + forked.validate_and_apply(&left).unwrap(); + forked.validate_and_apply(&right).unwrap(); + assert_eq!( + build_checkpoint_body(&forked, Timestamp::from_unix_millis(4)), + Err(IdentityError::AccountForked) + ); +} + +#[test] +fn destructive_transition_checkpoint_retains_and_replays_exact_authority() { + let (genesis, mut state, signer) = fixture(); + let retirement = authorized_operation( + &state, + &signer, + AccountOperation::RetireAccount( + RetireAccount::try_new(ProtocolVersion::V1, None, None, Extensions::default()).unwrap(), + ), + 5, + ); + let unrelated = authorized_operation( + &state, + &signer, + AccountOperation::RetireAccount( + RetireAccount::try_new(ProtocolVersion::V1, None, None, Extensions::default()).unwrap(), + ), + 6, + ); + state.validate_and_apply(&retirement).unwrap(); + let body = build_checkpoint_body(&state, Timestamp::from_unix_millis(5)).unwrap(); + assert_eq!(body.lifecycle(), AccountLifecycle::Retired); + let checkpoint = SignedCheckpoint::new( + body, + CheckpointAuthorization::transition_derived(&retirement).unwrap(), + ) + .unwrap(); + + let verified = verify_checkpoint(&state, &checkpoint, Some(&retirement)).unwrap(); + assert_eq!(verified.transition_event(), Some(&retirement)); + assert_eq!( + verify_checkpoint(&state, &checkpoint, None), + Err(IdentityError::InvalidProof) + ); + assert_eq!( + verify_checkpoint(&state, &checkpoint, Some(&unrelated)), + Err(IdentityError::InvalidProof) + ); + + let evidence = FreshnessEvidence::local_known(verified.checkpoint_id()); + let trusted = bootstrap_checkpoint_from_genesis( + &genesis, + std::slice::from_ref(&retirement), + &checkpoint, + Some(&retirement), + &evidence, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(100), + &[], + ) + .unwrap(); + assert_eq!(trusted.checkpoint().transition_event(), Some(&retirement)); + assert_eq!(trusted.state(), &state); +} + +#[test] +fn device_authorization_and_revocation_change_exact_checkpoint_sets() { + let (_, mut state, signer) = fixture(); + let descriptor = device_descriptor( + &SecretKey::from_bytes(&[0xa1; 32]), + &SecretKey::from_bytes(&[0xa2; 32]), + ); + let device_id = descriptor.id().unwrap(); + let authorization = DeviceAuthorization::new( + device_id, + descriptor, + DeviceClass::ApplicationOnly, + None, + Vec::new(), + state.epoch().checked_next().unwrap(), + Extensions::default(), + ) + .unwrap(); + let authorize = authorized_operation( + &state, + &signer, + AccountOperation::AuthorizeDevice(authorization), + 7, + ); + state.validate_and_apply(&authorize).unwrap(); + let authorized_body = build_checkpoint_body(&state, Timestamp::from_unix_millis(7)).unwrap(); + let authorized_sets = build_checkpoint_merkle_sets(&state).unwrap(); + assert_ne!(authorized_body.authorized_set_root(), empty_merkle_root()); + assert_eq!(authorized_body.revoked_set_root(), empty_merkle_root()); + assert_eq!( + authorized_sets.authorized_devices().root().unwrap(), + authorized_body.authorized_set_root() + ); + let authorized_key = MerkleSetKey::new( + CHECKPOINT_AUTHORIZED_DEVICE_TYPE_TAG, + *device_id.as_digest(), + ) + .unwrap(); + let authorized_leaf = authorized_sets + .authorized_devices() + .entries() + .iter() + .find(|leaf| leaf.key() == authorized_key) + .unwrap(); + authorized_sets + .authorized_devices() + .inclusion_proof(authorized_key) + .unwrap() + .verify(authorized_leaf, authorized_body.authorized_set_root()) + .unwrap(); + verify_checkpoint( + &state, + &signed_checkpoint(&state, &signer, authorized_body.clone()), + None, + ) + .unwrap(); + + let revoke = authorized_operation( + &state, + &signer, + AccountOperation::RevokeDevice( + RevokeDevice::new(device_id, None, Extensions::default()).unwrap(), + ), + 8, + ); + state.validate_and_apply(&revoke).unwrap(); + let revoked_body = build_checkpoint_body(&state, Timestamp::from_unix_millis(8)).unwrap(); + let revoked_sets = build_checkpoint_merkle_sets(&state).unwrap(); + assert_ne!(revoked_body.state_root(), authorized_body.state_root()); + assert_eq!(revoked_body.authorized_set_root(), empty_merkle_root()); + assert_ne!(revoked_body.revoked_set_root(), empty_merkle_root()); + revoked_sets + .authorized_devices() + .non_membership_proof(authorized_key) + .unwrap() + .verify(authorized_key, revoked_body.authorized_set_root()) + .unwrap(); + let revoked_key = + MerkleSetKey::new(CHECKPOINT_REVOKED_DEVICE_TYPE_TAG, *device_id.as_digest()).unwrap(); + let revoked_leaf = revoked_sets + .revoked_devices() + .entries() + .iter() + .find(|leaf| leaf.key() == revoked_key) + .unwrap(); + revoked_sets + .revoked_devices() + .inclusion_proof(revoked_key) + .unwrap() + .verify(revoked_leaf, revoked_body.revoked_set_root()) + .unwrap(); + verify_checkpoint( + &state, + &signed_checkpoint(&state, &signer, revoked_body), + None, + ) + .unwrap(); +} + +#[test] +fn prior_checkpoint_bootstrap_requires_the_complete_advancing_lineage() { + let (_, mut prior_state, signer) = fixture(); + let first = event(&prior_state, &signer, 0xb1, 9); + prior_state.validate_and_apply(&first).unwrap(); + let prior_checkpoint = signed_checkpoint( + &prior_state, + &signer, + build_checkpoint_body(&prior_state, Timestamp::from_unix_millis(9)).unwrap(), + ); + let verified_prior = verify_checkpoint(&prior_state, &prior_checkpoint, None).unwrap(); + + let second = event(&prior_state, &signer, 0xb2, 10); + let mut current_state = prior_state.clone(); + current_state.validate_and_apply(&second).unwrap(); + let current_checkpoint = signed_checkpoint( + ¤t_state, + &signer, + build_checkpoint_body(¤t_state, Timestamp::from_unix_millis(10)).unwrap(), + ); + let current_id = current_checkpoint.checkpoint_id().unwrap(); + let evidence = FreshnessEvidence::local_known(current_id); + let trusted = bootstrap_checkpoint_from_prior( + &prior_state, + &verified_prior, + std::slice::from_ref(&second), + ¤t_checkpoint, + None, + &evidence, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(10), + &[], + ) + .unwrap(); + assert_eq!(trusted.state(), ¤t_state); + assert_eq!(trusted.checkpoint().checkpoint_id(), current_id); + assert!( + bootstrap_checkpoint_from_prior( + &prior_state, + &verified_prior, + &[], + ¤t_checkpoint, + None, + &evidence, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(10), + &[], + ) + .is_err() + ); +} + +#[test] +fn replicated_bootstrap_requires_verified_policy_compatible_provider_evidence() { + let provider_secret = SecretKey::from_bytes(&[0xc2; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let provider_policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let (genesis, mut state, signer) = fixture_with_provider(provider_policy.clone()); + let applied = event(&state, &signer, 0xc3, 11); + state.validate_and_apply(&applied).unwrap(); + let checkpoint = signed_checkpoint( + &state, + &signer, + build_checkpoint_body(&state, Timestamp::from_unix_millis(11)).unwrap(), + ); + let checkpoint_id = checkpoint.checkpoint_id().unwrap(); + let evidence = FreshnessEvidence::provider_quorum( + checkpoint_id, + provider_policy.id().unwrap(), + ProviderReceipts::new(vec![checkpoint_receipt( + &provider_secret, + &provider, + state.account_id(), + checkpoint_id, + true, + )]) + .unwrap(), + ) + .unwrap(); + let trusted = bootstrap_checkpoint_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &checkpoint, + None, + &evidence, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(50), + &[], + ) + .unwrap(); + assert_eq!(trusted.state(), &state); + assert_eq!( + trusted.freshness().required_quorum(), + Some(ProviderQuorum::new(1).unwrap()) + ); + + let forged = FreshnessEvidence::provider_quorum( + checkpoint_id, + provider_policy.id().unwrap(), + ProviderReceipts::new(vec![checkpoint_receipt( + &provider_secret, + &provider, + state.account_id(), + checkpoint_id, + false, + )]) + .unwrap(), + ) + .unwrap(); + assert_eq!( + bootstrap_checkpoint_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &checkpoint, + None, + &forged, + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(50), + &[], + ), + Err(IdentityError::InvalidSignature) + ); + let stricter = FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(2).unwrap(), DurationMillis::new(100)).unwrap(), + ); + assert_eq!( + bootstrap_checkpoint_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &checkpoint, + None, + &evidence, + stricter, + Timestamp::from_unix_millis(50), + &[], + ), + Err(IdentityError::FreshnessUnavailable) + ); +} + +#[test] +fn direct_checkpoint_requires_the_provider_policy_control_threshold() { + let first_secret = SecretKey::from_bytes(&[0xd1; 32]); + let second_secret = SecretKey::from_bytes(&[0xd2; 32]); + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::ChangeProviderPolicy, + RequiredWeight::new(2).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0xd3; 32], + Timestamp::from_unix_millis(1), + policy, + vec![controller(&first_secret), controller(&second_secret)], + recovery, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let event = authorized_operation( + &state, + &first_secret, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0xd4; 32]))), + 0xd4, + ); + state.validate_and_apply(&event).unwrap(); + let body = build_checkpoint_body(&state, Timestamp::from_unix_millis(10)).unwrap(); + let checkpoint_id = body.checkpoint_id().unwrap(); + let one_approval = SignedCheckpoint::new( + body.clone(), + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![checkpoint_approval( + &state, + &first_secret, + checkpoint_id, + )]) + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + verify_checkpoint(&state, &one_approval, None), + Err(IdentityError::AuthorizationDenied) + ); + + let threshold_approval = SignedCheckpoint::new( + body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![ + checkpoint_approval(&state, &first_secret, checkpoint_id), + checkpoint_approval(&state, &second_secret, checkpoint_id), + ]) + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + verify_checkpoint(&state, &threshold_approval, None).unwrap(); +} + +#[test] +fn provider_bundle_serves_lineage_and_rejects_late_historical_checkpoint_as_current() { + let (genesis, mut first_state, controller_secret) = fixture(); + let first_event = event(&first_state, &controller_secret, 0xe1, 0xe1); + first_state.validate_and_apply(&first_event).unwrap(); + let first_checkpoint = signed_checkpoint( + &first_state, + &controller_secret, + build_checkpoint_body(&first_state, Timestamp::from_unix_millis(10)).unwrap(), + ); + let first_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&first_event), + &first_checkpoint, + None, + ) + .unwrap(); + + let second_event = event(&first_state, &controller_secret, 0xe2, 0xe2); + let mut second_state = first_state.clone(); + second_state.validate_and_apply(&second_event).unwrap(); + let second_checkpoint = signed_checkpoint( + &second_state, + &controller_secret, + build_checkpoint_body(&second_state, Timestamp::from_unix_millis(20)).unwrap(), + ); + let second_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + &[first_event, second_event], + &second_checkpoint, + None, + ) + .unwrap(); + + let provider_secret = SecretKey::from_bytes(&[0xe3; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let mut log = MemoryTransparencyLog::new(provider, typed_id::(0xe4)); + let signer = TestProviderSigner(provider_secret); + log.append( + second_bundle.provider_log_admission(), + Timestamp::from_unix_millis(200), + &signer, + ) + .unwrap(); + let tree_size = log.tree_size().unwrap(); + assert_eq!( + log.append( + first_bundle.provider_log_admission(), + Timestamp::from_unix_millis(300), + &signer, + ), + Err(IdentityError::ProviderRollback) + ); + assert_eq!(log.tree_size().unwrap(), tree_size); + + let served = log + .latest_checkpoint_bundle(second_state.account_id()) + .unwrap() + .unwrap(); + assert_eq!( + served.verified_checkpoint().checkpoint_id(), + second_checkpoint.checkpoint_id().unwrap() + ); + let served_genesis = served.genesis().unwrap(); + let served_events = served.events(); + let served_checkpoint = served.verified_checkpoint().checkpoint(); + bootstrap_checkpoint_from_genesis( + served_genesis, + served_events, + served_checkpoint, + served.verified_checkpoint().transition_event(), + &FreshnessEvidence::local_known(served.verified_checkpoint().checkpoint_id()), + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(300), + &[], + ) + .unwrap(); +} + +#[test] +fn provider_bundle_requires_retained_prior_and_surfaces_equal_sequence_forks() { + let (genesis, mut first_state, controller_secret) = fixture(); + let first_event = event(&first_state, &controller_secret, 0xf1, 0xf1); + first_state.validate_and_apply(&first_event).unwrap(); + let first_checkpoint = signed_checkpoint( + &first_state, + &controller_secret, + build_checkpoint_body(&first_state, Timestamp::from_unix_millis(10)).unwrap(), + ); + let first_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&first_event), + &first_checkpoint, + None, + ) + .unwrap(); + + let continuation_event = event(&first_state, &controller_secret, 0xf2, 0xf2); + let mut continuation_state = first_state.clone(); + continuation_state + .validate_and_apply(&continuation_event) + .unwrap(); + let continuation_checkpoint = signed_checkpoint( + &continuation_state, + &controller_secret, + build_checkpoint_body(&continuation_state, Timestamp::from_unix_millis(20)).unwrap(), + ); + let continuation = krikos_identity::build_provider_checkpoint_bundle_from_prior( + &first_state, + first_bundle.verified_checkpoint(), + std::slice::from_ref(&continuation_event), + &continuation_checkpoint, + None, + ) + .unwrap(); + + let provider_secret = SecretKey::from_bytes(&[0xf3; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let signer = TestProviderSigner(provider_secret); + let mut log = MemoryTransparencyLog::new(provider, typed_id::(0xf4)); + assert_eq!( + log.append( + continuation.provider_log_admission(), + Timestamp::from_unix_millis(100), + &signer, + ), + Err(IdentityError::InvalidProof) + ); + assert_eq!(log.tree_size().unwrap(), 0); + + log.append( + first_bundle.provider_log_admission(), + Timestamp::from_unix_millis(101), + &signer, + ) + .unwrap(); + log.append( + continuation.provider_log_admission(), + Timestamp::from_unix_millis(102), + &signer, + ) + .unwrap(); + assert_eq!( + log.latest_checkpoint_bundle(first_state.account_id()) + .unwrap() + .unwrap() + .verified_checkpoint() + .checkpoint_id(), + continuation_checkpoint.checkpoint_id().unwrap() + ); + + let mut fork_state = AccountState::from_genesis(&genesis).unwrap(); + let fork_event = event(&fork_state, &controller_secret, 0xf5, 0xf5); + fork_state.validate_and_apply(&fork_event).unwrap(); + let fork_checkpoint = signed_checkpoint( + &fork_state, + &controller_secret, + build_checkpoint_body(&fork_state, Timestamp::from_unix_millis(30)).unwrap(), + ); + let fork_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&fork_event), + &fork_checkpoint, + None, + ) + .unwrap(); + let mut fork_log = + MemoryTransparencyLog::new(log.provider().clone(), typed_id::(0xf6)); + fork_log + .append( + first_bundle.provider_log_admission(), + Timestamp::from_unix_millis(200), + &signer, + ) + .unwrap(); + fork_log + .append( + fork_bundle.provider_log_admission(), + Timestamp::from_unix_millis(201), + &signer, + ) + .unwrap(); + assert_eq!( + fork_log.latest_checkpoint_bundle(first_state.account_id()), + Err(IdentityError::AccountForked) + ); + assert_eq!(fork_log.tree_size().unwrap(), 2); +} + +#[test] +fn provider_bundle_replays_complete_fork_evidence_through_explicit_resolution() { + let (genesis, genesis_state, controller_secret) = fixture(); + let first_branch = event(&genesis_state, &controller_secret, 0xfa, 0xfa); + let second_branch = event(&genesis_state, &controller_secret, 0xfb, 0xfb); + let mut resolved_state = genesis_state.clone(); + resolved_state.validate_and_apply(&first_branch).unwrap(); + assert_eq!( + resolved_state + .validate_and_apply(&second_branch) + .unwrap() + .disposition(), + krikos_identity::ApplyDisposition::ForkDetected + ); + let fork = ForkDescriptor::try_new( + ProtocolVersion::V1, + resolved_state.account_id(), + ForkCommonAncestor::Genesis(resolved_state.genesis_anchor()), + resolved_state.heads().to_vec(), + Extensions::default(), + ) + .unwrap(); + let resolution = authorized_operation_at_epoch( + &resolved_state, + &controller_secret, + AccountOperation::ResolveFork( + ResolveFork::try_new( + ProtocolVersion::V1, + fork, + first_branch.event_id().unwrap(), + Vec::new(), + Vec::new(), + Extensions::default(), + ) + .unwrap(), + ), + krikos_identity::Epoch::new(2), + 0xfc, + ); + resolved_state.validate_and_apply(&resolution).unwrap(); + let checkpoint = signed_checkpoint( + &resolved_state, + &controller_secret, + build_checkpoint_body(&resolved_state, Timestamp::from_unix_millis(40)).unwrap(), + ); + + let bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + &[first_branch, second_branch, resolution], + &checkpoint, + None, + ) + .unwrap(); + assert_eq!( + bundle.verified_checkpoint().checkpoint_id(), + checkpoint.checkpoint_id().unwrap() + ); +} + +#[test] +fn provider_log_does_not_choose_a_longer_fork_and_accepts_complete_resolution() { + let (genesis, genesis_state, controller_secret) = fixture(); + let short_event = event(&genesis_state, &controller_secret, 0xd1, 0xd1); + let mut short_state = genesis_state.clone(); + short_state.validate_and_apply(&short_event).unwrap(); + let short_checkpoint = signed_checkpoint( + &short_state, + &controller_secret, + build_checkpoint_body(&short_state, Timestamp::from_unix_millis(10)).unwrap(), + ); + let short_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&short_event), + &short_checkpoint, + None, + ) + .unwrap(); + + let long_first = event(&genesis_state, &controller_secret, 0xd2, 0xd2); + let mut long_state = genesis_state.clone(); + long_state.validate_and_apply(&long_first).unwrap(); + let long_second = event(&long_state, &controller_secret, 0xd3, 0xd3); + long_state.validate_and_apply(&long_second).unwrap(); + let long_checkpoint = signed_checkpoint( + &long_state, + &controller_secret, + build_checkpoint_body(&long_state, Timestamp::from_unix_millis(20)).unwrap(), + ); + let long_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + &[long_first.clone(), long_second.clone()], + &long_checkpoint, + None, + ) + .unwrap(); + + let provider_secret = SecretKey::from_bytes(&[0xd4; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + #[cfg(feature = "provider-store")] + let persistent_provider = provider.clone(); + let signer = TestProviderSigner(provider_secret); + let mut log = MemoryTransparencyLog::new(provider, typed_id::(0xd5)); + log.append( + short_bundle.provider_log_admission(), + Timestamp::from_unix_millis(100), + &signer, + ) + .unwrap(); + log.append( + long_bundle.provider_log_admission(), + Timestamp::from_unix_millis(101), + &signer, + ) + .unwrap(); + assert_eq!( + log.latest_checkpoint_bundle(genesis_state.account_id()), + Err(IdentityError::AccountForked) + ); + + let mut resolved_state = genesis_state.clone(); + resolved_state.validate_and_apply(&short_event).unwrap(); + assert_eq!( + resolved_state + .validate_and_apply(&long_first) + .unwrap() + .disposition(), + krikos_identity::ApplyDisposition::ForkDetected + ); + resolved_state.validate_and_apply(&long_second).unwrap(); + let fork = ForkDescriptor::try_new( + ProtocolVersion::V1, + resolved_state.account_id(), + ForkCommonAncestor::Genesis(resolved_state.genesis_anchor()), + resolved_state.heads().to_vec(), + Extensions::default(), + ) + .unwrap(); + let resolution = authorized_operation_at_epoch( + &resolved_state, + &controller_secret, + AccountOperation::ResolveFork( + ResolveFork::try_new( + ProtocolVersion::V1, + fork, + long_second.event_id().unwrap(), + Vec::new(), + Vec::new(), + Extensions::default(), + ) + .unwrap(), + ), + krikos_identity::Epoch::new(3), + 0xd6, + ); + resolved_state.validate_and_apply(&resolution).unwrap(); + let resolved_checkpoint = signed_checkpoint( + &resolved_state, + &controller_secret, + build_checkpoint_body(&resolved_state, Timestamp::from_unix_millis(30)).unwrap(), + ); + let resolved_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + &[short_event, long_first, long_second, resolution], + &resolved_checkpoint, + None, + ) + .unwrap(); + log.append( + resolved_bundle.provider_log_admission(), + Timestamp::from_unix_millis(102), + &signer, + ) + .unwrap(); + assert_eq!( + log.latest_checkpoint_bundle(genesis_state.account_id()) + .unwrap() + .unwrap() + .verified_checkpoint() + .checkpoint_id(), + resolved_checkpoint.checkpoint_id().unwrap() + ); + + #[cfg(feature = "provider-store")] + { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("resolved-provider.redb"); + let log_id = typed_id::(0xd7); + { + let store = RedbProviderStore::open( + &path, + persistent_provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + for (offset, bundle) in [&short_bundle, &long_bundle, &resolved_bundle] + .into_iter() + .enumerate() + { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &AllowProviderAdmission) + .unwrap(), + Timestamp::from_unix_millis(200 + u64::try_from(offset).unwrap()), + &signer, + ) + .unwrap(); + } + assert_eq!( + store + .latest_checkpoint_bundle(genesis_state.account_id()) + .unwrap() + .unwrap(), + resolved_bundle + ); + } + + let reopened = RedbProviderStore::open( + &path, + persistent_provider, + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let current = reopened + .latest_checkpoint_bundle(genesis_state.account_id()) + .unwrap() + .unwrap(); + assert_eq!(current, resolved_bundle); + assert_eq!( + current + .verified_checkpoint() + .checkpoint() + .body() + .event_head(), + resolved_checkpoint.body().event_head() + ); + } +} + +#[test] +fn provider_log_treats_recheckpointing_the_same_state_as_non_forking() { + let (genesis, mut state, controller_secret) = fixture(); + let applied = event(&state, &controller_secret, 0xe5, 0xe5); + state.validate_and_apply(&applied).unwrap(); + let first = signed_checkpoint( + &state, + &controller_secret, + build_checkpoint_body(&state, Timestamp::from_unix_millis(10)).unwrap(), + ); + let second = signed_checkpoint( + &state, + &controller_secret, + build_checkpoint_body(&state, Timestamp::from_unix_millis(11)).unwrap(), + ); + let first_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &first, + None, + ) + .unwrap(); + let second_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&applied), + &second, + None, + ) + .unwrap(); + let provider_secret = SecretKey::from_bytes(&[0xe6; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let signer = TestProviderSigner(provider_secret); + let mut log = MemoryTransparencyLog::new(provider, typed_id::(0xe7)); + log.append( + first_bundle.provider_log_admission(), + Timestamp::from_unix_millis(100), + &signer, + ) + .unwrap(); + log.append( + second_bundle.provider_log_admission(), + Timestamp::from_unix_millis(101), + &signer, + ) + .unwrap(); + assert_eq!( + log.latest_checkpoint_bundle(state.account_id()) + .unwrap() + .unwrap() + .verified_checkpoint() + .checkpoint_id(), + second.checkpoint_id().unwrap() + ); +} + +#[cfg(feature = "provider-store")] +#[test] +fn provider_rotation_is_an_account_authorized_descriptor_and_log_generation_boundary() { + let old_provider_secret = SecretKey::from_bytes(&[0xe1; 32]); + let new_provider_secret = SecretKey::from_bytes(&[0xe2; 32]); + let old_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*old_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let new_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*new_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let old_policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![old_provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let new_policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS.checked_next().unwrap(), + vec![new_provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let (genesis, mut state, controller_secret) = fixture_with_provider(old_policy.clone()); + let before_rotation = event(&state, &controller_secret, 0xe3, 0xe3); + state.validate_and_apply(&before_rotation).unwrap(); + let old_checkpoint = signed_checkpoint( + &state, + &controller_secret, + build_checkpoint_body(&state, Timestamp::from_unix_millis(10)).unwrap(), + ); + let old_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&before_rotation), + &old_checkpoint, + None, + ) + .unwrap(); + assert_eq!( + old_checkpoint.body().provider_policy_id(), + old_policy.id().unwrap() + ); + + let rotate = authorized_operation( + &state, + &controller_secret, + AccountOperation::ChangeProviderPolicy(new_policy.clone()), + 0xe4, + ); + state.validate_and_apply(&rotate).unwrap(); + let new_checkpoint = signed_checkpoint( + &state, + &controller_secret, + build_checkpoint_body(&state, Timestamp::from_unix_millis(20)).unwrap(), + ); + let new_bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + &[before_rotation, rotate.clone()], + &new_checkpoint, + None, + ) + .unwrap(); + assert_eq!( + rotate.body().operation().kind(), + OperationKind::ChangeProviderPolicy + ); + assert_eq!( + new_checkpoint.body().provider_policy_id(), + new_policy.id().unwrap() + ); + + let directory = tempfile::tempdir().unwrap(); + let old_path = directory.path().join("provider-old.redb"); + let new_path = directory.path().join("provider-new.redb"); + let rejected_path = directory.path().join("provider-in-place-version.redb"); + let old_log_id = typed_id::(0xe5); + let new_log_id = typed_id::(0xe6); + let old_signer = TestProviderSigner(old_provider_secret); + let new_signer = TestProviderSigner(new_provider_secret); + { + let old_store = RedbProviderStore::open( + &old_path, + old_provider.clone(), + old_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let admission = old_bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + old_store + .append( + authorize_provider_append(admission, request, &AllowProviderAdmission).unwrap(), + Timestamp::from_unix_millis(100), + &old_signer, + ) + .unwrap(); + + let new_store = RedbProviderStore::open( + &new_path, + new_provider.clone(), + new_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let admission = new_bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + new_store + .append( + authorize_provider_append(admission, request, &AllowProviderAdmission).unwrap(), + Timestamp::from_unix_millis(101), + &new_signer, + ) + .unwrap(); + } + + assert!(matches!( + RedbProviderStore::open( + &rejected_path, + new_provider.clone(), + new_log_id, + ProviderKeyVersion::new(1), + ), + Err(IdentityError::InvalidRelationship { + resource: "provider signing-key generation", + }) + )); + let old_reopened = RedbProviderStore::open( + &old_path, + old_provider, + old_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + assert_eq!( + old_reopened + .latest_checkpoint_bundle(state.account_id()) + .unwrap() + .unwrap(), + old_bundle + ); + let new_reopened = RedbProviderStore::open( + &new_path, + new_provider, + new_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + assert_eq!( + new_reopened + .latest_checkpoint_bundle(state.account_id()) + .unwrap() + .unwrap(), + new_bundle + ); + let rollback = old_bundle.provider_log_admission(); + let rollback_request = ProviderAdmissionRequest::for_admission(&rollback).unwrap(); + assert_eq!( + new_reopened.append( + authorize_provider_append(rollback, rollback_request, &AllowProviderAdmission).unwrap(), + Timestamp::from_unix_millis(102), + &new_signer, + ), + Err(IdentityError::ProviderRollback) + ); + assert_eq!(new_reopened.snapshot().unwrap().tree_size(), 1); +} diff --git a/protocols/krikos-identity/tests/checkpoint_schema.rs b/protocols/krikos-identity/tests/checkpoint_schema.rs new file mode 100644 index 00000000000..68d96600cf1 --- /dev/null +++ b/protocols/krikos-identity/tests/checkpoint_schema.rs @@ -0,0 +1,268 @@ +use krikos_identity::{ + AccountId, AccountLifecycle, AccountOperation, AdmissionEvidence, AlgorithmSignature, + AuthorizedEvent, CanonicalWire, CheckpointAuthorization, CheckpointBody, CheckpointId, + CheckpointTransitionKind, ControlPolicyId, ControllerApprovalBody, ControllerApprovals, + ControllerId, ControllerKeyId, CryptoStateId, CryptoSuiteId, DelayEvidence, Digest, Epoch, + EventBody, EventId, EventPredecessors, Extension, Extensions, FreshnessEvidence, HashAlgorithm, + IdentityError, InclusionReceipt, KeyedSignature, ProtocolSignature, ProtocolVersion, + ProviderHeadBody, ProviderId, ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, + ProviderLogSubject, ProviderPolicy, ProviderPolicyId, ProviderPolicyVersion, ProviderReceipts, + RecoveryPolicyId, RetireAccount, Sequence, SignedCheckpoint, SignedControllerApproval, + SignedProviderHead, Timestamp, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn unknown_critical_extensions() -> Extensions { + Extensions::new(vec![Extension::new(65_535, true, vec![1]).unwrap()]).unwrap() +} + +fn authorized_retirement() -> AuthorizedEvent { + authorized_event(AccountOperation::RetireAccount( + RetireAccount::try_new(ProtocolVersion::V1, None, None, Extensions::default()).unwrap(), + )) +} + +fn authorized_event(operation: AccountOperation) -> AuthorizedEvent { + let body = EventBody::new( + typed_id::(20), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id(21)), + operation, + Timestamp::from_unix_millis(22), + [23; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(24); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + typed_id::(25), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let approval_body = ControllerApprovalBody::event( + typed_id::(26), + evidence.event_id_for_body(&body).unwrap(), + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + typed_id::(27), + typed_id::(28), + AlgorithmSignature::new(1, vec![29; 64]).unwrap(), + )], + ) + .unwrap(); + AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn checkpoint_body(event_head: EventId, lifecycle: AccountLifecycle) -> CheckpointBody { + CheckpointBody::new( + typed_id(20), + Epoch::new(1), + Sequence::new(1), + event_head, + Digest::new(HashAlgorithm::Blake3_256, [30; 32]), + Digest::new(HashAlgorithm::Blake3_256, [31; 32]), + Digest::new(HashAlgorithm::Blake3_256, [32; 32]), + typed_id::(33), + typed_id::(34), + typed_id::(35), + typed_id::(36), + lifecycle, + Timestamp::from_unix_millis(37), + Extensions::default(), + ) + .unwrap() +} + +#[test] +fn transition_checkpoint_witness_is_typed_eligible_and_head_bound() { + let event = authorized_retirement(); + let event_id = event.event_id().unwrap(); + let authorization = CheckpointAuthorization::transition_derived(&event).unwrap(); + let witness = authorization.transition_witness().unwrap(); + assert_eq!( + witness.transition_kind(), + CheckpointTransitionKind::RetireAccount + ); + assert_eq!(witness.event_id(), event_id); + assert_eq!( + witness.event_authorization_id(), + event.event_authorization_id().unwrap() + ); + + let checkpoint = SignedCheckpoint::new( + checkpoint_body(event_id, AccountLifecycle::Retired), + authorization.clone(), + ) + .unwrap(); + assert_eq!( + SignedCheckpoint::from_canonical_bytes(&checkpoint.to_canonical_bytes().unwrap()).unwrap(), + checkpoint + ); + + assert!(matches!( + SignedCheckpoint::new( + checkpoint_body(typed_id(38), AccountLifecycle::Retired), + authorization.clone(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + assert!(matches!( + SignedCheckpoint::new( + checkpoint_body(event_id, AccountLifecycle::Active), + authorization, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let ordinary = authorized_event(AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + )); + assert!(matches!( + CheckpointAuthorization::transition_derived(&ordinary), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn checkpoint_constructors_reject_unknown_critical_extensions() { + assert!(matches!( + ProviderLogEntryBody::new( + typed_id(1), + typed_id(2), + typed_id(3), + ProviderLogSubject::Checkpoint(typed_id(4)), + Timestamp::from_unix_millis(5), + unknown_critical_extensions(), + ), + Err(IdentityError::UnknownCriticalExtension { code: 65_535 }) + )); + assert!(matches!( + ProviderHeadBody::new( + typed_id(1), + typed_id(2), + ProviderKeyVersion::GENESIS, + 1, + Digest::new(HashAlgorithm::Blake3_256, [3; 32]), + Timestamp::from_unix_millis(5), + unknown_critical_extensions(), + ), + Err(IdentityError::UnknownCriticalExtension { code: 65_535 }) + )); + assert!(matches!( + CheckpointBody::new( + typed_id(1), + Epoch::new(2), + Sequence::new(3), + typed_id(4), + Digest::new(HashAlgorithm::Blake3_256, [5; 32]), + Digest::new(HashAlgorithm::Blake3_256, [6; 32]), + Digest::new(HashAlgorithm::Blake3_256, [7; 32]), + typed_id::(8), + typed_id::(9), + typed_id::(10), + typed_id::(11), + AccountLifecycle::Active, + Timestamp::from_unix_millis(12), + unknown_critical_extensions(), + ), + Err(IdentityError::UnknownCriticalExtension { code: 65_535 }) + )); +} + +#[test] +fn provider_receipts_are_bounded_sorted_and_subject_consistent() { + let account_id: AccountId = typed_id(1); + let checkpoint_id: CheckpointId = typed_id(2); + let provider_id: ProviderId = typed_id(3); + let log_id: ProviderLogId = typed_id(4); + let entry = ProviderLogEntryBody::new( + provider_id, + log_id, + account_id, + ProviderLogSubject::Checkpoint(checkpoint_id), + Timestamp::from_unix_millis(100), + Extensions::default(), + ) + .unwrap(); + let head = ProviderHeadBody::new( + provider_id, + log_id, + ProviderKeyVersion::GENESIS, + 1, + Digest::new(HashAlgorithm::Blake3_256, [5; 32]), + Timestamp::from_unix_millis(101), + Extensions::default(), + ) + .unwrap(); + let receipt = InclusionReceipt::new( + entry, + 0, + vec![], + SignedProviderHead::new(head, ProtocolSignature::ed25519([6; 64])), + ) + .unwrap(); + let receipts = ProviderReceipts::new(vec![receipt.clone()]).unwrap(); + assert_eq!(receipts.as_slice(), std::slice::from_ref(&receipt)); + + assert!(ProviderReceipts::new(vec![receipt.clone(), receipt]).is_err()); + assert_eq!( + ProviderReceipts::from_canonical_bytes(&receipts.to_canonical_bytes().unwrap()).unwrap(), + receipts + ); +} + +#[test] +fn checkpoint_id_hashes_only_the_canonical_body() { + let body = CheckpointBody::new( + typed_id(1), + Epoch::new(2), + Sequence::new(3), + typed_id(4), + Digest::new(HashAlgorithm::Blake3_256, [5; 32]), + Digest::new(HashAlgorithm::Blake3_256, [6; 32]), + Digest::new(HashAlgorithm::Blake3_256, [7; 32]), + typed_id::(8), + typed_id::(9), + typed_id::(10), + typed_id::(11), + AccountLifecycle::Active, + Timestamp::from_unix_millis(12), + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = body.checkpoint_id().unwrap(); + assert_eq!( + hex::encode(body.to_canonical_bytes().unwrap()), + "010101010101010101010101010101010101010101010101010101010101010101010203010404040404040404040404040404040404040404040404040404040404040404010505050505050505050505050505050505050505050505050505050505050505010606060606060606060606060606060606060606060606060606060606060606010707070707070707070707070707070707070707070707070707070707070707010808080808080808080808080808080808080808080808080808080808080808010909090909090909090909090909090909090909090909090909090909090909010a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a010b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b010c00" + ); + assert_eq!( + checkpoint_id.to_string(), + "b3:2ec9928a9a1c43fdaf59ac0228675cbb7249d25ea44acd54961e8318f5078e4e" + ); + assert_eq!( + CheckpointBody::from_canonical_bytes(&body.to_canonical_bytes().unwrap()) + .unwrap() + .checkpoint_id() + .unwrap(), + checkpoint_id + ); +} diff --git a/protocols/krikos-identity/tests/device_application_schema.rs b/protocols/krikos-identity/tests/device_application_schema.rs new file mode 100644 index 00000000000..d6cf344d05c --- /dev/null +++ b/protocols/krikos-identity/tests/device_application_schema.rs @@ -0,0 +1,462 @@ +use krikos_identity::{ + AccountId, AgreementPublicKey, ApplicationEventBody, ApplicationEventCounter, ApplicationId, + CanonicalWire, CapabilityAction, CapabilityGrant, CapabilityNamespace, CheckpointId, + CryptoSuiteDescriptor, CryptoSuiteId, DelegationPermission, DeviceAuthorization, + DeviceAuthorizationUpdate, DeviceClass, DeviceDescriptor, DeviceId, DeviceMetadataUpdate, + DeviceUpdate, Digest, EndpointPublicKey, Epoch, Extension, Extensions, GroupId, GroupKeyEpoch, + GroupKeyWrapHeader, HashAlgorithm, IdentityError, KeyWrapNonce, ProtocolSignature, + RecipientKeyWraps, ReinstateDevice, ResourcePath, ResourceSelector, RevocationReasonCode, + RevokeDevice, RotateDeviceKeys, SignedApplicationEvent, SigningPublicKey, SuspendDevice, + WrappedGroupKey, + limits::{MAX_APPLICATION_PAYLOAD_BYTES, MAX_CAPABILITIES_PER_DEVICE, MAX_KEY_WRAP_BYTES}, +}; + +const SIGNING_KEY_1: [u8; 32] = [ + 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a, + 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a, +]; +const SIGNING_KEY_2: [u8; 32] = [ + 0x3d, 0x40, 0x17, 0xc3, 0xe8, 0x43, 0x89, 0x5a, 0x92, 0xb7, 0x0a, 0xa7, 0x4d, 0x1b, 0x7e, 0xbc, + 0x9c, 0x98, 0x2c, 0xcf, 0x2e, 0xc4, 0x96, 0x8c, 0xc0, 0xcd, 0x55, 0xf1, 0x2a, 0xf4, 0x66, 0x0c, +]; +const SIGNING_KEY_3: [u8; 32] = [ + 0xfc, 0x51, 0xcd, 0x8e, 0x62, 0x18, 0xa1, 0xa3, 0x8d, 0xa4, 0x7e, 0xd0, 0x02, 0x30, 0xf0, 0x58, + 0x08, 0x16, 0xed, 0x13, 0xba, 0x33, 0x03, 0xac, 0x5d, 0xeb, 0x91, 0x15, 0x48, 0x90, 0x80, 0x25, +]; + +fn digest(seed: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [seed; 32]) +} + +fn typed_id(seed: u8) -> T { + T::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn v1_suite_id() -> CryptoSuiteId { + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap() +} + +fn signing_key(bytes: [u8; 32]) -> SigningPublicKey { + SigningPublicKey::ed25519(bytes).unwrap() +} + +fn descriptor(seed: u8) -> DeviceDescriptor { + let signing = if seed & 1 == 0 { + SIGNING_KEY_1 + } else { + SIGNING_KEY_2 + }; + let endpoint = if seed & 1 == 0 { + SIGNING_KEY_2 + } else { + SIGNING_KEY_3 + }; + let mut agreement = [0_u8; 32]; + agreement[0] = seed.max(9); + DeviceDescriptor::new( + signing_key(signing), + AgreementPublicKey::x25519(agreement).unwrap(), + EndpointPublicKey::new(signing_key(endpoint)), + Extensions::default(), + ) + .unwrap() +} + +fn capability(namespace: &str, action: &str) -> CapabilityGrant { + CapabilityGrant::new( + CapabilityNamespace::new(namespace).unwrap(), + CapabilityAction::new(action).unwrap(), + ResourceSelector::exact(ResourcePath::new(vec![b"root".to_vec()]).unwrap()).unwrap(), + Vec::new(), + DelegationPermission::NotDelegable, + None, + Extensions::default(), + ) + .unwrap() +} + +fn commitment(seed: u8) -> krikos_identity::BlindedMetadataCommitment { + let mut bytes = [0_u8; 32]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = seed.wrapping_add(u8::try_from(index).unwrap()); + } + krikos_identity::BlindedMetadataCommitment::new(bytes).unwrap() +} + +fn authorization(seed: u8) -> DeviceAuthorization { + let descriptor = descriptor(seed); + DeviceAuthorization::new( + descriptor.id().unwrap(), + descriptor, + DeviceClass::ApplicationOnly, + Some(commitment(seed)), + vec![capability("krikos.test", "read")], + Epoch::new(2), + Extensions::default(), + ) + .unwrap() +} + +#[test] +fn device_classes_commitments_and_updates_are_closed_and_canonical() { + assert_eq!(DeviceClass::GeneralPurpose.code(), 1); + assert_eq!(DeviceClass::HardwareBacked.code(), 2); + assert_eq!(DeviceClass::ApplicationOnly.code(), 3); + assert_eq!(DeviceClass::Service.code(), 4); + assert_eq!( + DeviceClass::GeneralPurpose.to_canonical_bytes().unwrap(), + [1] + ); + assert!(DeviceClass::from_canonical_bytes(&[5]).is_err()); + + assert!(krikos_identity::BlindedMetadataCommitment::new([0; 32]).is_err()); + assert!(krikos_identity::BlindedMetadataCommitment::new([7; 32]).is_err()); + + let auth = authorization(9); + let authorization_update = DeviceAuthorizationUpdate::new( + auth.device_id(), + DeviceClass::HardwareBacked, + vec![capability("krikos.test", "write")], + Epoch::new(3), + Extensions::default(), + ) + .unwrap(); + let metadata_update = DeviceMetadataUpdate::new( + auth.device_id(), + Some(commitment(31)), + Extensions::default(), + ) + .unwrap(); + let authorization_wire = DeviceUpdate::Authorization(authorization_update) + .to_canonical_bytes() + .unwrap(); + let metadata_wire = DeviceUpdate::Metadata(metadata_update) + .to_canonical_bytes() + .unwrap(); + assert_eq!(authorization_wire[0], 1); + assert_eq!(metadata_wire[0], 2); + assert!( + DeviceUpdate::from_canonical_bytes(&postcard::to_stdvec(&(99_u16, ())).unwrap()).is_err() + ); +} + +#[test] +fn authorization_binds_descriptor_and_rejects_oversized_or_noncanonical_grant_sets() { + let descriptor = descriptor(10); + let wrong_id: DeviceId = typed_id(99); + assert!(matches!( + DeviceAuthorization::new( + wrong_id, + descriptor.clone(), + DeviceClass::GeneralPurpose, + None, + Vec::new(), + Epoch::GENESIS, + Extensions::default(), + ), + Err(IdentityError::InvalidIdentifier { .. }) + )); + + let grant = capability("krikos.test", "read"); + assert!(matches!( + DeviceAuthorization::new( + descriptor.id().unwrap(), + descriptor.clone(), + DeviceClass::GeneralPurpose, + None, + vec![grant; MAX_CAPABILITIES_PER_DEVICE + 1], + Epoch::GENESIS, + Extensions::default(), + ), + Err(IdentityError::LimitExceeded { .. }) + )); + + let mut grants = vec![ + capability("krikos.test", "read"), + capability("krikos.test", "write"), + ]; + grants.sort_unstable_by_key(|grant| grant.capability_grant_id().unwrap()); + grants.reverse(); + let unsorted = postcard::to_stdvec(&( + krikos_identity::ProtocolVersion::V1, + descriptor.id().unwrap(), + descriptor, + DeviceClass::GeneralPurpose, + Option::::None, + grants, + Epoch::GENESIS, + Extensions::default(), + )) + .unwrap(); + assert!(DeviceAuthorization::from_canonical_bytes(&unsorted).is_err()); +} + +#[test] +fn lifecycle_payloads_are_typed_and_rotation_is_atomic() { + let old = authorization(9); + let new = authorization(10); + let suspend = SuspendDevice::new(old.device_id(), Extensions::default()).unwrap(); + let reinstate = ReinstateDevice::new(old.device_id(), Extensions::default()).unwrap(); + let revoke = RevokeDevice::new( + old.device_id(), + Some(RevocationReasonCode::new(7).unwrap()), + Extensions::default(), + ) + .unwrap(); + assert_eq!(suspend.device_id(), reinstate.device_id()); + assert_eq!(revoke.reason_code().unwrap().get(), 7); + assert!(RotateDeviceKeys::new(old.device_id(), old.clone(), Extensions::default()).is_err()); + + let rotation = RotateDeviceKeys::new(old.device_id(), new, Extensions::default()).unwrap(); + assert_ne!( + rotation.old_device_id(), + rotation.new_authorization().device_id() + ); + assert_eq!( + RotateDeviceKeys::from_canonical_bytes(&rotation.to_canonical_bytes().unwrap()).unwrap(), + rotation + ); + + let critical = Extensions::new(vec![Extension::new(77, true, vec![1]).unwrap()]).unwrap(); + assert!(DeviceMetadataUpdate::new(old.device_id(), None, critical).is_err()); +} + +#[test] +fn signed_application_event_is_context_bound_bounded_and_stable() { + let auth = authorization(9); + let body = ApplicationEventBody::new( + typed_id::(1), + ApplicationId::new(digest(2)), + auth.device_id(), + Epoch::new(4), + typed_id::(3), + ApplicationEventCounter::new(8), + b"ok".to_vec(), + Extensions::default(), + ) + .unwrap(); + let signed = SignedApplicationEvent::new(body, ProtocolSignature::ed25519([5; 64])).unwrap(); + signed.validate_authorization(&auth).unwrap(); + assert_eq!( + SignedApplicationEvent::from_canonical_bytes(&signed.to_canonical_bytes().unwrap()) + .unwrap(), + signed + ); + assert_eq!( + signed.application_event_id().unwrap().to_string(), + "b3:72587781c758650dfe6fa6a7dbd3dc1dad7aa464c79c13b3f89cd46cc8b1a285" + ); + assert_eq!( + hex::encode(signed.to_canonical_bytes().unwrap()), + "01010101010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202017e5dc5cf5bb6d50a38d7bd5cca8e6e8d6bf653c38ed04155fb8ce4aa45988ffb0401030303030303030303030303030303030303030303030303030303030303030308026f6b000105050505050505050505050505050505050505050505050505050505050505050505050505050505050505050505050505050505050505050505050505050505" + ); + + let stale = ApplicationEventBody::new( + typed_id::(1), + ApplicationId::new(digest(2)), + auth.device_id(), + Epoch::new(1), + typed_id::(3), + ApplicationEventCounter::GENESIS, + Vec::new(), + Extensions::default(), + ) + .unwrap(); + let stale = SignedApplicationEvent::new(stale, ProtocolSignature::ed25519([5; 64])).unwrap(); + assert!(stale.validate_authorization(&auth).is_err()); + + assert!( + ApplicationEventBody::new( + typed_id::(1), + ApplicationId::new(digest(2)), + auth.device_id(), + Epoch::new(4), + typed_id::(3), + ApplicationEventCounter::GENESIS, + vec![0; MAX_APPLICATION_PAYLOAD_BYTES + 1], + Extensions::default(), + ) + .is_err() + ); + let large_extensions = Extensions::new( + (1_u32..=4) + .map(|code| { + Extension::new(code, false, vec![u8::try_from(code).unwrap(); 16 * 1024]).unwrap() + }) + .collect(), + ) + .unwrap(); + let oversized_envelope_body = ApplicationEventBody::new( + typed_id::(1), + ApplicationId::new(digest(2)), + auth.device_id(), + Epoch::new(4), + typed_id::(3), + ApplicationEventCounter::GENESIS, + vec![0; MAX_APPLICATION_PAYLOAD_BYTES], + large_extensions, + ) + .unwrap(); + assert!( + SignedApplicationEvent::new(oversized_envelope_body, ProtocolSignature::ed25519([5; 64]),) + .is_err() + ); + let critical = Extensions::new(vec![Extension::new(91, true, vec![1]).unwrap()]).unwrap(); + assert!( + ApplicationEventBody::new( + typed_id::(1), + ApplicationId::new(digest(2)), + auth.device_id(), + Epoch::new(4), + typed_id::(3), + ApplicationEventCounter::GENESIS, + Vec::new(), + critical, + ) + .is_err() + ); + assert!( + ApplicationEventCounter::new(u64::MAX) + .checked_next() + .is_err() + ); +} + +#[test] +fn key_wrap_header_binds_recipient_and_context() { + let auth = authorization(9); + let suite_id = v1_suite_id(); + let account_id: AccountId = typed_id(12); + let application_id = ApplicationId::new(digest(13)); + let group_id = GroupId::new(digest(14)); + let ephemeral = AgreementPublicKey::x25519({ + let mut bytes = [0; 32]; + bytes[0] = 21; + bytes + }) + .unwrap(); + let nonce = KeyWrapNonce::new([19; 24]); + let header = GroupKeyWrapHeader::new_for_recipient( + suite_id, + account_id, + application_id, + group_id, + Epoch::new(4), + GroupKeyEpoch::new(2), + &auth, + ephemeral, + nonce, + Extensions::default(), + ) + .unwrap(); + header.validate_recipient(&auth).unwrap(); + assert_eq!(header.recipient_device_id(), auth.device_id()); + + let wrong_auth = authorization(10); + assert!(header.validate_recipient(&wrong_auth).is_err()); + assert_eq!(KeyWrapNonce::new([0; 24]).as_bytes(), &[0; 24]); + assert!( + GroupKeyWrapHeader::new_for_recipient( + suite_id, + account_id, + application_id, + group_id, + Epoch::new(4), + GroupKeyEpoch::new(2), + &auth, + auth.descriptor().agreement_key(), + nonce, + Extensions::default(), + ) + .is_err() + ); + let critical = Extensions::new(vec![Extension::new(92, true, vec![1]).unwrap()]).unwrap(); + assert!( + GroupKeyWrapHeader::new_for_recipient( + suite_id, + account_id, + application_id, + group_id, + Epoch::new(4), + GroupKeyEpoch::new(2), + &auth, + ephemeral, + nonce, + critical, + ) + .is_err() + ); +} + +#[test] +fn wrapped_keys_are_bounded_sorted_unique_and_have_stable_ids() { + let first = authorization(9); + let second = authorization(10); + let suite_id = v1_suite_id(); + let account_id: AccountId = typed_id(22); + let application_id = ApplicationId::new(digest(23)); + let group_id = GroupId::new(digest(24)); + let make_wrap = |authorization: &DeviceAuthorization, nonce_seed: u8| { + let ephemeral = AgreementPublicKey::x25519({ + let mut bytes = [0; 32]; + bytes[0] = nonce_seed.wrapping_add(17); + bytes + }) + .unwrap(); + let header = GroupKeyWrapHeader::new_for_recipient( + suite_id, + account_id, + application_id, + group_id, + Epoch::new(7), + GroupKeyEpoch::new(3), + authorization, + ephemeral, + KeyWrapNonce::new([nonce_seed; 24]), + Extensions::default(), + ) + .unwrap(); + WrappedGroupKey::new(header, vec![nonce_seed; 48], Extensions::default()).unwrap() + }; + let first_wrap = make_wrap(&first, 31); + let second_wrap = make_wrap(&second, 32); + assert_eq!( + first_wrap.group_key_wrap_id().unwrap().to_string(), + "b3:1196705741b216350b7aa5a0b81c30177b7537c35ddeeb7f3fe4a3c8740c7816" + ); + let actual_wrap_bytes = hex::encode(first_wrap.to_canonical_bytes().unwrap()); + let expected_wrap_bytes = format!( + "{}{}00", + "01018ff40ee1a62f16342b90d738eb35827198fecb38c8b8cef4e949427a1d7b27ea0116161616161616161616161616161616161616161616161616161616161616160117171717171717171717171717171717171717171717171717171717171717170118181818181818181818181818181818181818181818181818181818181818180703017e5dc5cf5bb6d50a38d7bd5cca8e6e8d6bf653c38ed04155fb8ce4aa45988ffb01e427efacc4b7fe639631f9af0447d539676a9627f5a928eb4f6410988fee2d2f0130000000000000000000000000000000000000000000000000000000000000001f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f0030", + "1f".repeat(48), + ); + assert_eq!(actual_wrap_bytes.len(), expected_wrap_bytes.len()); + assert_eq!(actual_wrap_bytes, expected_wrap_bytes); + assert!( + WrappedGroupKey::new( + first_wrap.header().clone(), + vec![0; MAX_KEY_WRAP_BYTES + 1], + Extensions::default(), + ) + .is_err() + ); + assert!(RecipientKeyWraps::new(vec![first_wrap.clone(), first_wrap.clone()]).is_err()); + let reused_ephemeral_and_nonce = make_wrap(&second, 31); + assert!(RecipientKeyWraps::new(vec![first_wrap.clone(), reused_ephemeral_and_nonce]).is_err()); + + let set = RecipientKeyWraps::new(vec![second_wrap.clone(), first_wrap.clone()]).unwrap(); + assert_eq!(set.as_slice().len(), 2); + assert!(set.as_slice()[0].recipient_device_id() < set.as_slice()[1].recipient_device_id()); + assert_eq!( + RecipientKeyWraps::from_canonical_bytes(&set.to_canonical_bytes().unwrap()).unwrap(), + set + ); + + let mut reversed = set.as_slice().to_vec(); + reversed.reverse(); + let noncanonical = postcard::to_stdvec(&reversed).unwrap(); + assert!(RecipientKeyWraps::from_canonical_bytes(&noncanonical).is_err()); +} diff --git a/protocols/krikos-identity/tests/event_evidence.rs b/protocols/krikos-identity/tests/event_evidence.rs new file mode 100644 index 00000000000..c4f94aa467f --- /dev/null +++ b/protocols/krikos-identity/tests/event_evidence.rs @@ -0,0 +1,237 @@ +use krikos_identity::{ + AccountId, AdmissionEvidence, AdmissionEvidenceId, AlgorithmSignature, CanonicalWire, + CheckpointId, ControllerApprovalBody, ControllerApprovals, ControllerId, ControllerKeyId, + CryptoSuiteId, DelayEvidence, Digest, EventId, EventIntentApprovalBody, EventIntentApprovals, + Extension, Extensions, FreshnessEvidence, HashAlgorithm, IdentityError, InclusionReceipt, + KeyedSignature, ProposalId, ProtocolSignature, ProviderHeadBody, ProviderId, + ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, ProviderPolicyId, + ProviderQuorum, ProviderReceipts, SignedControllerApproval, SignedEventIntentApproval, + SignedProviderHead, Timestamp, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn unknown_critical_extensions() -> Extensions { + Extensions::new(vec![Extension::new(65_535, true, vec![1]).unwrap()]).unwrap() +} + +#[test] +fn approval_constructors_reject_unknown_critical_extensions() { + assert!(matches!( + EventIntentApprovalBody::new(typed_id(1), typed_id(2), unknown_critical_extensions(),), + Err(IdentityError::UnknownCriticalExtension { code: 65_535 }) + )); + assert!(matches!( + ControllerApprovalBody::event( + typed_id(1), + typed_id(2), + typed_id(3), + unknown_critical_extensions(), + ), + Err(IdentityError::UnknownCriticalExtension { code: 65_535 }) + )); + assert!(matches!( + ControllerApprovalBody::checkpoint(typed_id(1), typed_id(2), unknown_critical_extensions(),), + Err(IdentityError::UnknownCriticalExtension { code: 65_535 }) + )); +} + +fn intent_receipt( + provider_fill: u8, + account_id: AccountId, + proposal_id: ProposalId, + observed_at: u64, +) -> InclusionReceipt { + let provider_id = typed_id::(provider_fill); + let log_id = typed_id::(provider_fill.wrapping_add(32)); + let entry = ProviderLogEntryBody::new( + provider_id, + log_id, + account_id, + ProviderLogSubject::EventIntent(proposal_id), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + let head = ProviderHeadBody::new( + provider_id, + log_id, + ProviderKeyVersion::GENESIS, + 1, + Digest::new(HashAlgorithm::Blake3_256, [provider_fill; 32]), + Timestamp::from_unix_millis(observed_at + 1), + Extensions::default(), + ) + .unwrap(); + InclusionReceipt::new( + entry, + 0, + vec![], + SignedProviderHead::new(head, ProtocolSignature::ed25519([provider_fill; 64])), + ) + .unwrap() +} + +#[test] +fn admission_and_approval_ids_exclude_signature_subsets() { + let proposal_id: ProposalId = typed_id(1); + let event_id: EventId = typed_id(2); + let evidence = AdmissionEvidence::new( + proposal_id, + typed_id::(3), + typed_id::(4), + FreshnessEvidence::local_known(typed_id(3)), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let evidence_id: AdmissionEvidenceId = evidence.admission_evidence_id().unwrap(); + assert_eq!( + hex::encode(evidence.to_canonical_bytes().unwrap()), + "01010101010101010101010101010101010101010101010101010101010101010101010303030303030303030303030303030303030303030303030303030303030303010404040404040404040404040404040404040404040404040404040404040404010103030303030303030303030303030303030303030303030303030303030303030000" + ); + assert_eq!( + evidence_id.to_string(), + "b3:dfbcc98e0ebe7d270a8d5095f4ca78ca5d566daf20b96563692f15189b682f40" + ); + let body = ControllerApprovalBody::event( + typed_id::(5), + event_id, + evidence_id, + Extensions::default(), + ) + .unwrap(); + let approval_id = body.controller_approval_id().unwrap(); + assert_eq!( + approval_id.to_string(), + "b3:1b5ed735ed22133d37a57ab444766ce2ed29f1b7e3e37375f52ddaf34b1f43b4" + ); + let one_signature = SignedControllerApproval::new( + body.clone(), + vec![KeyedSignature::new( + typed_id::(6), + typed_id::(7), + AlgorithmSignature::new(1, vec![8; 64]).unwrap(), + )], + ) + .unwrap(); + let two_signatures = SignedControllerApproval::new( + body.clone(), + vec![ + KeyedSignature::new( + typed_id::(6), + typed_id::(7), + AlgorithmSignature::new(1, vec![8; 64]).unwrap(), + ), + KeyedSignature::new( + typed_id::(9), + typed_id::(10), + AlgorithmSignature::new(2, vec![11; 96]).unwrap(), + ), + ], + ) + .unwrap(); + + assert_eq!( + one_signature.merge(&two_signatures).unwrap(), + two_signatures + ); + assert_eq!( + ControllerApprovals::new(vec![one_signature.clone()]) + .unwrap() + .merge(&ControllerApprovals::new(vec![two_signatures.clone()]).unwrap()) + .unwrap() + .as_slice(), + std::slice::from_ref(&two_signatures) + ); + let conflicting = SignedControllerApproval::new( + body, + vec![KeyedSignature::new( + typed_id::(6), + typed_id::(7), + AlgorithmSignature::new(1, vec![12; 64]).unwrap(), + )], + ) + .unwrap(); + assert!(matches!( + one_signature.merge(&conflicting), + Err(IdentityError::InvalidSignature) + )); + + assert_ne!( + one_signature.to_canonical_bytes().unwrap(), + two_signatures.to_canonical_bytes().unwrap() + ); + assert_eq!( + one_signature.body().controller_approval_id().unwrap(), + approval_id + ); + assert_eq!( + two_signatures.body().controller_approval_id().unwrap(), + approval_id + ); +} + +#[test] +fn admission_evidence_fuzz_seed_tracks_the_v1_wire() { + let seed = include_bytes!("../../../fuzz/corpus/identity_schema/admission-evidence-v1"); + let (&selector, payload) = seed.split_first().unwrap(); + assert_eq!(selector, 43); + let evidence = AdmissionEvidence::from_canonical_bytes(payload).unwrap(); + assert_eq!(evidence.to_canonical_bytes().unwrap(), payload); +} + +#[test] +fn delayed_evidence_freezes_the_quorum_th_earliest_observation() { + let account_id = typed_id::(20); + let proposal_id = typed_id::(21); + let intent_body = EventIntentApprovalBody::new( + typed_id::(22), + proposal_id, + Extensions::default(), + ) + .unwrap(); + let intent = SignedEventIntentApproval::new( + intent_body, + vec![KeyedSignature::new( + typed_id::(23), + typed_id::(24), + AlgorithmSignature::new(1, vec![25; 64]).unwrap(), + )], + ) + .unwrap(); + let approvals = EventIntentApprovals::new(vec![intent]).unwrap(); + let receipts = ProviderReceipts::new(vec![ + intent_receipt(3, account_id, proposal_id, 300), + intent_receipt(1, account_id, proposal_id, 100), + intent_receipt(2, account_id, proposal_id, 200), + ]) + .unwrap(); + let evidence = DelayEvidence::provider_quorum( + typed_id::(26), + ProviderQuorum::new(2).unwrap(), + approvals.clone(), + receipts.clone(), + ) + .unwrap(); + assert_eq!( + evidence.observed_at(), + Some(Timestamp::from_unix_millis(200)) + ); + assert_eq!( + DelayEvidence::from_canonical_bytes(&evidence.to_canonical_bytes().unwrap()).unwrap(), + evidence + ); + assert!(matches!( + DelayEvidence::provider_quorum( + typed_id::(26), + ProviderQuorum::new(4).unwrap(), + approvals, + receipts, + ), + Err(krikos_identity::IdentityError::UnsatisfiableThreshold) + )); +} diff --git a/protocols/krikos-identity/tests/freshness_decision.rs b/protocols/krikos-identity/tests/freshness_decision.rs new file mode 100644 index 00000000000..a9ad70170b1 --- /dev/null +++ b/protocols/krikos-identity/tests/freshness_decision.rs @@ -0,0 +1,332 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, AuthorizationContext, CanonicalWire, CheckpointId, Digest, DurationMillis, Epoch, + Extensions, FreshnessEvidence, FreshnessRequirement, HashAlgorithm, IdentityError, + InclusionReceipt, ProtocolSignature, ProviderDescriptor, ProviderFreshness, ProviderHeadBody, + ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, ProviderPolicy, + ProviderPolicyVersion, ProviderQuorum, ProviderReceipts, SignedProviderHead, SigningPublicKey, + Timestamp, evaluate_freshness, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn descriptor(secret: &SecretKey) -> ProviderDescriptor { + ProviderDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +fn receipt( + secret: &SecretKey, + provider: &ProviderDescriptor, + context: AuthorizationContext, + entry_time: u64, + head_time: u64, + fill: u8, +) -> InclusionReceipt { + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + typed_id::(fill), + context.account_id(), + ProviderLogSubject::Checkpoint(context.checkpoint_id()), + Timestamp::from_unix_millis(entry_time), + Extensions::default(), + ) + .unwrap(); + let body = ProviderHeadBody::new( + provider.id().unwrap(), + entry.log_id(), + ProviderKeyVersion::GENESIS, + 1, + entry.merkle_leaf_hash().unwrap(), + Timestamp::from_unix_millis(head_time), + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&body.signing_bytes().unwrap()); + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(body, ProtocolSignature::ed25519(signature.to_bytes())), + ) + .unwrap() +} + +#[test] +fn caller_and_account_freshness_combine_only_monotonically() { + let first_secret = SecretKey::from_bytes(&[0x21; 32]); + let second_secret = SecretKey::from_bytes(&[0x22; 32]); + let first = descriptor(&first_secret); + let second = descriptor(&second_secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![first.clone(), second.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(2).unwrap(), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let context = AuthorizationContext::new( + typed_id::(0x31), + Epoch::new(7), + typed_id::(0x32), + ); + let receipts = ProviderReceipts::new(vec![ + receipt(&first_secret, &first, context, 10, 60, 0x41), + receipt(&second_secret, &second, context, 20, 70, 0x42), + ]) + .unwrap(); + let evidence = + FreshnessEvidence::provider_quorum(context.checkpoint_id(), policy.id().unwrap(), receipts) + .unwrap(); + let account_requirement = FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(1).unwrap(), DurationMillis::new(100)).unwrap(), + ); + let caller_requirement = FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(2).unwrap(), DurationMillis::new(50)).unwrap(), + ); + + let decision = evaluate_freshness( + context, + &policy, + account_requirement, + caller_requirement, + &evidence, + Timestamp::from_unix_millis(60), + ) + .unwrap(); + assert_eq!(decision.context(), context); + assert_eq!( + decision.required_quorum(), + Some(ProviderQuorum::new(2).unwrap()) + ); + assert_eq!(decision.maximum_age(), Some(DurationMillis::new(50))); + assert_eq!( + decision.provider_observed_at(), + Some(Timestamp::from_unix_millis(20)) + ); +} + +#[test] +fn latest_known_makes_no_online_or_provider_time_claim() { + let policy = + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(); + let context = AuthorizationContext::new( + typed_id::(0x33), + Epoch::new(2), + typed_id::(0x34), + ); + let decision = evaluate_freshness( + context, + &policy, + FreshnessRequirement::latest_known(), + FreshnessRequirement::latest_known(), + &FreshnessEvidence::local_known(context.checkpoint_id()), + Timestamp::from_unix_millis(1), + ) + .unwrap(); + assert_eq!(decision.provider_observed_at(), None); + assert_eq!(decision.required_quorum(), None); +} + +#[test] +fn provider_rotation_and_exact_signed_age_boundary_fail_closed() { + let old_secret = SecretKey::from_bytes(&[0x23; 32]); + let current_secret = SecretKey::from_bytes(&[0x24; 32]); + let old_provider = descriptor(&old_secret); + let current_provider = descriptor(¤t_secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::new(2), + vec![current_provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let context = AuthorizationContext::new( + typed_id::(0x35), + Epoch::new(3), + typed_id::(0x36), + ); + let requirement = FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(1).unwrap(), DurationMillis::new(100)).unwrap(), + ); + + let rotated_out = FreshnessEvidence::provider_quorum( + context.checkpoint_id(), + policy.id().unwrap(), + ProviderReceipts::new(vec![receipt( + &old_secret, + &old_provider, + context, + 100, + 100, + 0x43, + )]) + .unwrap(), + ) + .unwrap(); + assert_eq!( + evaluate_freshness( + context, + &policy, + requirement, + requirement, + &rotated_out, + Timestamp::from_unix_millis(100), + ), + Err(IdentityError::FreshnessUnavailable) + ); + + let exact_boundary = FreshnessEvidence::provider_quorum( + context.checkpoint_id(), + policy.id().unwrap(), + ProviderReceipts::new(vec![receipt( + ¤t_secret, + ¤t_provider, + context, + 100, + 200, + 0x44, + )]) + .unwrap(), + ) + .unwrap(); + let decision = evaluate_freshness( + context, + &policy, + requirement, + FreshnessRequirement::latest_known(), + &exact_boundary, + Timestamp::from_unix_millis(200), + ) + .unwrap(); + assert_eq!( + decision.provider_observed_at(), + Some(Timestamp::from_unix_millis(100)) + ); + + let stale = FreshnessEvidence::provider_quorum( + context.checkpoint_id(), + policy.id().unwrap(), + ProviderReceipts::new(vec![receipt( + ¤t_secret, + ¤t_provider, + context, + 100, + 201, + 0x45, + )]) + .unwrap(), + ) + .unwrap(); + assert_eq!( + evaluate_freshness( + context, + &policy, + requirement, + requirement, + &stale, + Timestamp::from_unix_millis(201), + ), + Err(IdentityError::StaleEvidence) + ); + + let impossible_caller = FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(2).unwrap(), DurationMillis::new(100)).unwrap(), + ); + assert_eq!( + evaluate_freshness( + context, + &policy, + requirement, + impossible_caller, + &exact_boundary, + Timestamp::from_unix_millis(200), + ), + Err(IdentityError::FreshnessUnavailable) + ); +} + +#[test] +fn initial_receipt_replay_expires_at_explicit_verifier_time() { + let provider_secret = SecretKey::from_bytes(&[0x51; 32]); + let provider = descriptor(&provider_secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let context = AuthorizationContext::new( + typed_id::(0x52), + Epoch::new(4), + typed_id::(0x53), + ); + let requirement = FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(1).unwrap(), DurationMillis::new(100)).unwrap(), + ); + let evidence = FreshnessEvidence::provider_quorum( + context.checkpoint_id(), + policy.id().unwrap(), + ProviderReceipts::new(vec![receipt( + &provider_secret, + &provider, + context, + 100, + 100, + 0x54, + )]) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + evaluate_freshness( + context, + &policy, + requirement, + requirement, + &evidence, + Timestamp::from_unix_millis(10_000), + ), + Err(IdentityError::StaleEvidence) + ); + + let replay_under_later_head = FreshnessEvidence::provider_quorum( + context.checkpoint_id(), + policy.id().unwrap(), + ProviderReceipts::new(vec![receipt( + &provider_secret, + &provider, + context, + 100, + 10_000, + 0x55, + )]) + .unwrap(), + ) + .unwrap(); + assert_eq!( + evaluate_freshness( + context, + &policy, + requirement, + requirement, + &replay_under_later_head, + Timestamp::from_unix_millis(10_000), + ), + Err(IdentityError::StaleEvidence) + ); +} diff --git a/protocols/krikos-identity/tests/genesis_schema.rs b/protocols/krikos-identity/tests/genesis_schema.rs new file mode 100644 index 00000000000..e46d436ceec --- /dev/null +++ b/protocols/krikos-identity/tests/genesis_schema.rs @@ -0,0 +1,134 @@ +use krikos_identity::{ + AccountGenesis, CanonicalWire, ControlPolicy, ControllerClass, ControllerDescriptor, + ControllerScope, ControllerSelector, ControllerThreshold, ControllerWeight, DurationMillis, + Extensions, FreshnessRequirement, IdentityError, OperationKind, PolicyRule, ProviderPolicy, + ProviderPolicyVersion, RecoveryAuthority, RecoveryPolicy, RecoveryPolicyVersion, + RequiredWeight, SigningPublicKey, Timestamp, limits::MAX_CONTROLLERS, +}; + +const VALID_KEY: [u8; 32] = [ + 0xae, 0x58, 0xff, 0x88, 0x33, 0x24, 0x1a, 0xc8, 0x2d, 0x6f, 0xf7, 0x61, 0x10, 0x46, 0xed, 0x67, + 0xb5, 0x07, 0x2d, 0x14, 0x2c, 0x58, 0x8d, 0x00, 0x63, 0xe9, 0x42, 0xd9, 0xa7, 0x55, 0x02, 0xb6, +]; + +fn fixture() -> AccountGenesis { + let controller = ControllerDescriptor::new( + SigningPublicKey::ed25519(VALID_KEY).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap(); + let rules = [ + OperationKind::ChangeControlPolicy, + OperationKind::ResolveFork, + ] + .into_iter() + .map(|operation| { + PolicyRule::new( + operation, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap() + }) + .collect(); + let control = ControlPolicy::new(rules, Extensions::default()).unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(60_000), + DurationMillis::new(120_000), + Extensions::default(), + ) + .unwrap(); + AccountGenesis::new( + [7; 32], + Timestamp::from_unix_millis(1_700_000_000_000), + control, + vec![controller], + recovery, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +#[test] +fn genesis_bytes_anchor_and_account_id_are_stable() { + let genesis = fixture(); + let encoded = genesis.to_canonical_bytes().unwrap(); + assert_eq!( + hex::encode(&encoded), + concat!( + "010707070707070707070707070707070707070707070707070707070707070707", + "80d095ffbc310101020a01010000010000001101010000010000000100010101ae", + "58ff8833241ac82d6ff7611046ed67b5072d142c588d0063e942d9a75502b60101", + "010000010001010100000100e0d403c0a90700010001000000" + ) + ); + assert_eq!( + genesis.account_id().unwrap().to_string(), + "b3:af84c06d8905295e7231e960820bb57f73ab31b5fab87b490bcf6de3feac1ce7" + ); + assert_eq!( + genesis.genesis_anchor().unwrap().to_string(), + "b3:c0733bc78c4136c28b6fb0582d578134abdb2223961364c912fe8c02ea0f5f5b" + ); + assert_eq!( + AccountGenesis::from_canonical_bytes(&encoded).unwrap(), + genesis + ); + assert_ne!( + genesis.genesis_anchor().unwrap().as_digest(), + genesis.account_id().unwrap().as_digest() + ); +} + +#[test] +fn genesis_rejects_zero_nonce_and_duplicate_controller_keys() { + let valid = fixture(); + assert!( + valid + .initial_policy() + .validate_satisfiable(valid.initial_controllers()) + .is_ok() + ); + assert!( + AccountGenesis::new( + [0; 32], + valid.created_at(), + valid.initial_policy().clone(), + valid.initial_controllers().to_vec(), + valid.initial_recovery_policy().clone(), + valid.initial_provider_policy().clone(), + Extensions::default(), + ) + .is_err() + ); + + let repeated_controller = valid.initial_controllers()[0].clone(); + assert!(matches!( + AccountGenesis::new( + [7; 32], + valid.created_at(), + valid.initial_policy().clone(), + vec![repeated_controller; MAX_CONTROLLERS + 1], + valid.initial_recovery_policy().clone(), + valid.initial_provider_policy().clone(), + Extensions::default(), + ), + Err(IdentityError::LimitExceeded { + resource: "initial controllers", + actual, + maximum: MAX_CONTROLLERS, + }) if actual == MAX_CONTROLLERS + 1 + )); +} diff --git a/protocols/krikos-identity/tests/interop_vectors.rs b/protocols/krikos-identity/tests/interop_vectors.rs new file mode 100644 index 00000000000..b468c97766a --- /dev/null +++ b/protocols/krikos-identity/tests/interop_vectors.rs @@ -0,0 +1,4896 @@ +#![cfg(feature = "net")] + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, +}; + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + Key, XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, Payload}, +}; +use krikos_base::{PublicKey, SecretKey, Signature}; +use krikos_identity::{ + merkle::{ + MerkleConsistencyProof, MerkleInclusionProof, MerkleNonMembershipProof, MerkleSetKey, + MerkleSetLeaf, + }, + net::{ + AuthorizedCheckpointRequest, AuthorizedProposalRequest, AuthorizedSyncRequest, + EndpointAuthorizationRequest, IdentityProtocolAck, IdentityProtocolKind, + IdentityProtocolReply, IdentityServiceOutcome, + }, + *, +}; +use serde::{Deserialize, Serialize}; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct Manifest { + format: String, + format_version: u16, + binding_schema_version: u16, + derivation_schema_version: u16, + canonical_profile: String, + algorithms: BTreeMap, + deterministic_keys: Vec, + private_wire_exclusions: Vec, + transient_wire_dispositions: Vec, + required_inventory: Vec, + vectors: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct KeyMetadata { + name: String, + algorithm: String, + test_only_secret_seed_hex: String, + public_key_hex: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct Exclusion { + wire_type: String, + reason: String, + covered_by: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct VectorMetadata { + name: String, + wire_type: String, + canonical_file: String, + canonical_hex: String, + canonical_blake3_hex: String, + encoded_length: usize, + protocol_version: Option, + version_scope: String, + algorithms: Vec, + expected_ids: BTreeMap, + signature_bindings: Vec, + mac_bindings: Vec, + derivations: Vec, + dependencies: Vec, + tamper_cases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct SignatureBinding { + name: String, + algorithm: String, + domain_ascii: String, + message_hex: String, + signer_key: String, + public_key_hex: String, + signature_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct MacBinding { + name: String, + algorithm: String, + key_derivation_algorithm: String, + key_derivation_context_ascii: String, + key_derivation_input_hex: String, + message_domain_ascii: String, + message_hex: String, + expected_mac_hex: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct DerivationMetadata { + output_name: String, + algorithm: String, + domain_or_context_ascii: String, + message_hex: String, + expected_output_hex: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct TamperMetadata { + name: String, + offset: usize, + replacement_hex: String, + expectation: String, +} + +// This is deliberately source-owned and independent of the generator/catalog. If a generator +// regression drops both a fixture and its manifest entry, this closed set still fails the build. +const REQUIRED_VECTOR_NAMES: &[&str] = &[ + "account-genesis", + "account-operation-01", + "account-operation-02", + "account-operation-03", + "account-operation-04", + "account-operation-05", + "account-operation-06", + "account-operation-07", + "account-operation-08", + "account-operation-09", + "account-operation-10", + "account-operation-11", + "account-operation-12", + "account-operation-13", + "account-operation-14", + "account-operation-15", + "account-operation-16", + "account-operation-17", + "account-operation-18", + "account-operation-19", + "account-operation-20", + "account-operation-21", + "account-operation-22", + "admission-evidence", + "application-event-body", + "authorized-checkpoint-request", + "authorized-event", + "authorized-proposal-request", + "authorized-sync-request", + "backup-authority-bundle", + "backup-envelope", + "capability-grant", + "capability-root", + "capability-root-grant", + "checkpoint-direct", + "checkpoint-migration-dual", + "checkpoint-migration-pending", + "checkpoint-transition-finalize", + "checkpoint-transition-retire", + "controller-approvals", + "controller-key-binding-proof", + "crypto-migration-begin", + "delegation-body", + "delegation-chain", + "device-authorization-proposal", + "endpoint-authorization-request", + "event-body", + "event-intent-approval", + "event-intent-approval-body", + "event-intent-approvals", + "final-event-controller-approval", + "final-event-controller-approval-body", + "fork-descriptor", + "group-key-wrap-header", + "guardian-approval-body", + "guardian-approval-set", + "guardian-threshold-evidence", + "id-account", + "id-admission-evidence", + "id-application", + "id-application-event", + "id-capability-grant", + "id-checkpoint", + "id-control-policy", + "id-controller", + "id-controller-approval", + "id-controller-key", + "id-crypto-migration", + "id-crypto-state", + "id-crypto-suite", + "id-delegation", + "id-device", + "id-event", + "id-event-authorization", + "id-event-intent-approval", + "id-fork", + "id-genesis-anchor", + "id-group", + "id-group-key-wrap", + "id-guardian-grant", + "id-proposal", + "id-provider", + "id-provider-log", + "id-provider-policy", + "id-recovery", + "id-recovery-policy", + "identity-protocol-ack", + "identity-protocol-reply-ack", + "identity-protocol-reply-sync", + "inclusion-receipt", + "merkle-consistency-proof", + "merkle-inclusion-proof", + "merkle-non-membership-proof", + "merkle-set-key", + "merkle-set-leaf", + "name-claim-body", + "opaque-provider-anchor-commitment", + "pairing-confirmation-context", + "pairing-possession-proof", + "pairing-ticket", + "pairing-transcript", + "portable-credential-body", + "presence-challenge", + "presence-proof", + "private-artifact-context", + "private-metadata-envelope", + "proposal-endpoint-authorization-request", + "provider-audit-export-chunk", + "provider-audit-export-manifest", + "provider-compaction-manifest", + "provider-equivocation-evidence", + "provider-export-component", + "provider-export-component-descriptor", + "provider-generation-export-chunk", + "provider-generation-export-manifest", + "provider-head-body", + "provider-log-entry", + "provider-receipts", + "provider-recovery-export-manifest", + "recipient-key-wraps", + "recovery-authority-plan", + "recovery-begin", + "recovery-cancel", + "recovery-delay-anchor", + "recovery-finalize", + "recovery-proposal", + "recovery-veto", + "signed-application-event", + "signed-delegation", + "signed-guardian-approval", + "signed-name-claim", + "signed-portable-credential", + "signed-provider-head", + "signed-social-attestation", + "social-attestation-body", + "sync-cursor", + "sync-frame", + "sync-request", + "sync-response-complete", + "sync-response-frame", + "wrapped-group-key", +]; + +const PAIRING_CONFIRMATION_DISPOSITION_REASON: &str = "public transient ceremony message intentionally has no CanonicalWire implementation and is consumed before retained proposal construction"; +const PAIRING_CONFIRMATION_DISPOSITION_COVERAGE: &str = "pairing ceremony state-machine tests plus PairingConfirmationContext and DeviceAuthorizationProposal vectors"; + +fn required_wire_type(name: &str) -> &'static str { + match name { + "account-operation-01" + | "account-operation-02" + | "account-operation-03" + | "account-operation-04" + | "account-operation-05" + | "account-operation-06" + | "account-operation-07" + | "account-operation-08" + | "account-operation-09" + | "account-operation-10" + | "account-operation-11" + | "account-operation-12" + | "account-operation-13" + | "account-operation-14" + | "account-operation-15" + | "account-operation-16" + | "account-operation-17" + | "account-operation-18" + | "account-operation-19" + | "account-operation-20" + | "account-operation-21" + | "account-operation-22" => "AccountOperation", + "checkpoint-direct" + | "checkpoint-migration-dual" + | "checkpoint-migration-pending" + | "checkpoint-transition-finalize" + | "checkpoint-transition-retire" => "SignedCheckpoint", + "identity-protocol-reply-ack" | "identity-protocol-reply-sync" => "IdentityProtocolReply", + "sync-response-complete" | "sync-response-frame" => "SyncResponse", + "account-genesis" => "AccountGenesis", + "admission-evidence" => "AdmissionEvidence", + "application-event-body" => "ApplicationEventBody", + "authorized-checkpoint-request" => "AuthorizedCheckpointRequest", + "authorized-event" => "AuthorizedEvent", + "authorized-proposal-request" => "AuthorizedProposalRequest", + "authorized-sync-request" => "AuthorizedSyncRequest", + "backup-authority-bundle" => "BackupAuthorityBundle", + "backup-envelope" => "BackupEnvelope", + "capability-grant" | "capability-root-grant" => "CapabilityGrant", + "capability-root" => "CapabilityRoot", + "controller-approvals" => "ControllerApprovals", + "controller-key-binding-proof" => "ControllerKeyBindingProof", + "crypto-migration-begin" => "BeginCryptoMigration", + "delegation-body" => "DelegationBody", + "delegation-chain" => "DelegationChain", + "device-authorization-proposal" => "DeviceAuthorizationProposal", + "endpoint-authorization-request" | "proposal-endpoint-authorization-request" => { + "EndpointAuthorizationRequest" + } + "event-body" => "EventBody", + "event-intent-approval" => "SignedEventIntentApproval", + "event-intent-approval-body" => "EventIntentApprovalBody", + "event-intent-approvals" => "EventIntentApprovals", + "final-event-controller-approval" => "SignedControllerApproval", + "final-event-controller-approval-body" => "ControllerApprovalBody", + "fork-descriptor" => "ForkDescriptor", + "group-key-wrap-header" => "GroupKeyWrapHeader", + "guardian-approval-body" => "GuardianApprovalBody", + "guardian-approval-set" => "GuardianApprovalSet", + "guardian-threshold-evidence" => "RecoveryThresholdEvidence", + "id-account" => "AccountId", + "id-admission-evidence" => "AdmissionEvidenceId", + "id-application" => "ApplicationId", + "id-application-event" => "ApplicationEventId", + "id-capability-grant" => "CapabilityGrantId", + "id-checkpoint" => "CheckpointId", + "id-control-policy" => "ControlPolicyId", + "id-controller" => "ControllerId", + "id-controller-approval" => "ControllerApprovalId", + "id-controller-key" => "ControllerKeyId", + "id-crypto-migration" => "CryptoMigrationId", + "id-crypto-state" => "CryptoStateId", + "id-crypto-suite" => "CryptoSuiteId", + "id-delegation" => "DelegationId", + "id-device" => "DeviceId", + "id-event" => "EventId", + "id-event-authorization" => "EventAuthorizationId", + "id-event-intent-approval" => "EventIntentApprovalId", + "id-fork" => "ForkId", + "id-genesis-anchor" => "GenesisAnchor", + "id-group" => "GroupId", + "id-group-key-wrap" => "GroupKeyWrapId", + "id-guardian-grant" => "GuardianGrantId", + "id-proposal" => "ProposalId", + "id-provider" => "ProviderId", + "id-provider-log" => "ProviderLogId", + "id-provider-policy" => "ProviderPolicyId", + "id-recovery" => "RecoveryId", + "id-recovery-policy" => "RecoveryPolicyId", + "identity-protocol-ack" => "IdentityProtocolAck", + "inclusion-receipt" => "InclusionReceipt", + "merkle-consistency-proof" => "MerkleConsistencyProof", + "merkle-inclusion-proof" => "MerkleInclusionProof", + "merkle-non-membership-proof" => "MerkleNonMembershipProof", + "merkle-set-key" => "MerkleSetKey", + "merkle-set-leaf" => "MerkleSetLeaf", + "name-claim-body" => "NameClaimBody", + "opaque-provider-anchor-commitment" => "OpaqueProviderAnchorCommitment", + "pairing-confirmation-context" => "PairingConfirmationContext", + "pairing-possession-proof" => "PairingPossessionProof", + "pairing-ticket" => "PairingTicket", + "pairing-transcript" => "PairingTranscript", + "portable-credential-body" => "PortableCredentialBody", + "presence-challenge" => "DevicePresenceChallenge", + "presence-proof" => "PresenceProof", + "private-artifact-context" => "PrivateArtifactContext", + "private-metadata-envelope" => "PrivateMetadataEnvelope", + "provider-audit-export-chunk" => "ProviderAuditExportChunk", + "provider-audit-export-manifest" => "ProviderAuditExportManifest", + "provider-compaction-manifest" => "ProviderCompactionManifest", + "provider-equivocation-evidence" => "ProviderEquivocationEvidence", + "provider-export-component" => "ProviderExportComponent", + "provider-export-component-descriptor" => "ProviderExportComponentDescriptor", + "provider-generation-export-chunk" => "ProviderGenerationExportChunk", + "provider-generation-export-manifest" => "ProviderGenerationExportManifest", + "provider-head-body" => "ProviderHeadBody", + "provider-log-entry" => "ProviderLogEntryBody", + "provider-receipts" => "ProviderReceipts", + "provider-recovery-export-manifest" => "ProviderRecoveryExportManifest", + "recipient-key-wraps" => "RecipientKeyWraps", + "recovery-authority-plan" => "RecoveryAuthorityPlan", + "recovery-begin" => "BeginRecovery", + "recovery-cancel" => "CancelRecovery", + "recovery-delay-anchor" => "RecoveryDelayAnchor", + "recovery-finalize" => "FinalizeRecovery", + "recovery-proposal" => "RecoveryProposal", + "recovery-veto" => "VetoRecovery", + "signed-application-event" => "SignedApplicationEvent", + "signed-delegation" => "SignedDelegation", + "signed-guardian-approval" => "SignedGuardianApproval", + "signed-name-claim" => "SignedNameClaim", + "signed-portable-credential" => "SignedPortableCredential", + "signed-provider-head" => "SignedProviderHead", + "signed-social-attestation" => "SignedSocialAttestation", + "social-attestation-body" => "SocialAttestationBody", + "sync-cursor" => "SyncCursor", + "sync-frame" => "SyncFrame", + "sync-request" => "SyncRequest", + "wrapped-group-key" => "WrappedGroupKey", + other => panic!("required vector {other} has no source-owned wire type"), + } +} + +fn assert_closed_inventory(manifest: &Manifest) { + assert_eq!( + manifest + .required_inventory + .iter() + .map(String::as_str) + .collect::>(), + REQUIRED_VECTOR_NAMES, + "manifest required_inventory must exactly equal the source-owned closed inventory" + ); + assert_eq!( + manifest + .vectors + .iter() + .map(|vector| vector.name.as_str()) + .collect::>(), + REQUIRED_VECTOR_NAMES, + "manifest vectors must exactly equal the source-owned closed inventory" + ); + for vector in &manifest.vectors { + assert_eq!( + vector.wire_type, + required_wire_type(&vector.name), + "{} must retain its source-owned wire type", + vector.name + ); + } + assert_eq!(manifest.transient_wire_dispositions.len(), 1); + let disposition = &manifest.transient_wire_dispositions[0]; + assert_eq!(disposition.wire_type, "PairingConfirmation"); + assert_eq!(disposition.reason, PAIRING_CONFIRMATION_DISPOSITION_REASON); + assert_eq!( + disposition.covered_by, + PAIRING_CONFIRMATION_DISPOSITION_COVERAGE + ); +} + +fn vector_directory() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors") +} + +#[test] +fn interoperability_manifest_requires_versioned_binding_and_inventory_schemas() { + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(vector_directory().join("manifest.json")).unwrap()) + .unwrap(); + assert_eq!(manifest["format_version"], 2); + assert_eq!(manifest["binding_schema_version"], 1); + assert_eq!(manifest["derivation_schema_version"], 1); + assert_eq!( + manifest["transient_wire_dispositions"][0]["wire_type"], + "PairingConfirmation" + ); + assert!( + manifest["required_inventory"] + .as_array() + .is_some_and(|inventory| inventory + .iter() + .any(|name| name == "crypto-migration-begin")), + "closed manifest inventory must require crypto-migration-begin independently of catalog contents" + ); + for vector in manifest["vectors"].as_array().unwrap() { + assert!(vector["signature_bindings"].is_array()); + assert!(vector["mac_bindings"].is_array()); + assert!(vector["derivations"].is_array()); + assert!(vector.get("message_hex").is_none()); + assert!(vector.get("public_key_hex").is_none()); + assert!(vector.get("signature_hex").is_none()); + } +} + +fn checked_in_manifest() -> Manifest { + serde_json::from_slice(&fs::read(vector_directory().join("manifest.json")).unwrap()).unwrap() +} + +#[test] +fn source_owned_inventory_rejects_catalog_deletion_even_when_manifest_inventory_is_also_deleted() { + let manifest = checked_in_manifest(); + assert_closed_inventory(&manifest); + + for victim in REQUIRED_VECTOR_NAMES { + let mut vectors_only = manifest.clone(); + vectors_only.vectors.retain(|vector| vector.name != *victim); + assert!( + std::panic::catch_unwind(|| assert_closed_inventory(&vectors_only)).is_err(), + "removing required vector {victim} must fail closed" + ); + + let mut coordinated_deletion = manifest.clone(); + coordinated_deletion + .vectors + .retain(|vector| vector.name != *victim); + coordinated_deletion + .required_inventory + .retain(|name| name != *victim); + assert!( + std::panic::catch_unwind(|| assert_closed_inventory(&coordinated_deletion)).is_err(), + "deleting {victim} from generated inventory must not hide catalog loss" + ); + } + + let mut coordinated_type_substitution = manifest.clone(); + coordinated_type_substitution + .vectors + .iter_mut() + .find(|vector| vector.name == "crypto-migration-begin") + .unwrap() + .wire_type = "AccountOperation".to_owned(); + assert!( + std::panic::catch_unwind(|| assert_closed_inventory(&coordinated_type_substitution)) + .is_err(), + "retaining a required name with a substituted wire type must fail closed" + ); + + let operation_18 = manifest + .vectors + .iter() + .find(|vector| vector.name == "account-operation-18") + .unwrap(); + let operation_17 = manifest + .vectors + .iter() + .find(|vector| vector.name == "account-operation-17") + .unwrap(); + let operation_17_bytes = + fs::read(vector_directory().join(&operation_17.canonical_file)).unwrap(); + assert!( + std::panic::catch_unwind(|| { + validate_source_owned_variant(operation_18, &operation_17_bytes) + }) + .is_err(), + "retaining an account-operation name with a substituted valid operation variant must fail closed" + ); + + for (target_name, replacement_name) in [ + ("checkpoint-direct", "checkpoint-migration-pending"), + ("checkpoint-migration-pending", "checkpoint-migration-dual"), + ("checkpoint-migration-dual", "checkpoint-migration-pending"), + ( + "checkpoint-transition-finalize", + "checkpoint-transition-retire", + ), + ( + "checkpoint-transition-retire", + "checkpoint-transition-finalize", + ), + ] { + let target = manifest + .vectors + .iter() + .find(|vector| vector.name == target_name) + .unwrap(); + let replacement = manifest + .vectors + .iter() + .find(|vector| vector.name == replacement_name) + .unwrap(); + let replacement_bytes = + fs::read(vector_directory().join(&replacement.canonical_file)).unwrap(); + assert!( + std::panic::catch_unwind(|| { + validate_source_owned_variant(target, &replacement_bytes) + }) + .is_err(), + "retaining {target_name} with valid {replacement_name} bytes must fail closed" + ); + } +} + +#[test] +fn every_declared_dependency_is_consumed_and_cannot_be_substituted() { + let manifest = checked_in_manifest(); + let directory = vector_directory(); + let vectors = manifest + .vectors + .iter() + .map(|vector| (vector.name.as_str(), vector)) + .collect::>(); + + for vector in manifest + .vectors + .iter() + .filter(|vector| !vector.dependencies.is_empty()) + { + let bytes = fs::read(directory.join(&vector.canonical_file)).unwrap(); + for dependency_index in 0..vector.dependencies.len() { + let replacement = manifest + .vectors + .iter() + .find(|candidate| { + candidate.name != vector.name && !vector.dependencies.contains(&candidate.name) + }) + .unwrap(); + let mut substituted = vector.clone(); + substituted.dependencies[dependency_index] = replacement.name.clone(); + assert!( + std::panic::catch_unwind(|| { + validate_cross_vector_dependencies(&substituted, &bytes, &directory, &vectors) + }) + .is_err(), + "{} dependency {} accepted coordinated substitution with {}", + vector.name, + vector.dependencies[dependency_index], + replacement.name + ); + } + } + + let mut unused_dependency = (*vectors["merkle-set-key"]).clone(); + unused_dependency.dependencies = vec!["account-genesis".to_owned()]; + let bytes = fs::read(directory.join(&unused_dependency.canonical_file)).unwrap(); + assert!( + std::panic::catch_unwind(|| { + validate_cross_vector_dependencies(&unused_dependency, &bytes, &directory, &vectors) + }) + .is_err(), + "a source-owned vector with no dependency rule accepted an unused dependency" + ); +} + +#[test] +fn merkle_nonmembership_consumes_exact_query_neighbors_and_root() { + let manifest = checked_in_manifest(); + let directory = vector_directory(); + let vectors = manifest + .vectors + .iter() + .map(|vector| (vector.name.as_str(), vector)) + .collect::>(); + let vector = vectors["merkle-non-membership-proof"]; + assert_eq!(vector.dependencies, ["merkle-set-key"]); + let proof = MerkleNonMembershipProof::from_canonical_bytes( + &fs::read(directory.join(&vector.canonical_file)).unwrap(), + ) + .unwrap(); + let missing_key = MerkleSetKey::from_canonical_bytes( + &fs::read(directory.join(&vectors["merkle-set-key"].canonical_file)).unwrap(), + ) + .unwrap(); + let derivations = merkle_non_membership_derivations(&proof); + let root_derivation = derivations.last().unwrap(); + assert_eq!(root_derivation.output_name, "merkle_root"); + let root = Digest::new( + HashAlgorithm::Blake3_256, + hex::decode(&root_derivation.expected_output_hex) + .unwrap() + .try_into() + .unwrap(), + ); + proof.verify(missing_key, root).unwrap(); + if let Some(predecessor) = proof.predecessor() { + assert!(predecessor.leaf().key() < missing_key); + } + if let Some(successor) = proof.successor() { + assert!(missing_key < successor.leaf().key()); + } +} + +fn visit_dependency_graph( + name: &str, + vectors: &BTreeMap<&str, &VectorMetadata>, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, +) { + if visited.contains(name) { + return; + } + assert!( + visiting.insert(name.to_owned()), + "interoperability dependency cycle reaches {name}" + ); + for dependency in &vectors[name].dependencies { + visit_dependency_graph(dependency, vectors, visiting, visited); + } + assert!(visiting.remove(name)); + assert!(visited.insert(name.to_owned())); +} + +fn validate_canonical(bytes: &[u8], name: &str) { + let decoded = T::from_canonical_bytes(bytes) + .unwrap_or_else(|error| panic!("{name} failed canonical decode: {error}")); + assert_eq!( + decoded.to_canonical_bytes().as_deref(), + Ok(bytes), + "{name} did not reproduce its checked-in canonical bytes" + ); +} + +macro_rules! validate_id_type { + ($wire_type:expr, $bytes:expr, $name:expr, $($type:ty),+ $(,)?) => { + match $wire_type { + $(stringify!($type) => validate_canonical::<$type>($bytes, $name),)+ + _ => unreachable!("caller checks non-ID types separately"), + } + }; +} + +fn validate_wire_type(vector: &VectorMetadata, bytes: &[u8]) { + validate_source_owned_variant(vector, bytes); + match vector.wire_type.as_str() { + "GenesisAnchor" + | "AccountId" + | "ControllerId" + | "ControllerKeyId" + | "ControlPolicyId" + | "RecoveryPolicyId" + | "ProviderId" + | "ProviderLogId" + | "ProviderPolicyId" + | "DeviceId" + | "CapabilityGrantId" + | "DelegationId" + | "ProposalId" + | "EventId" + | "EventAuthorizationId" + | "AdmissionEvidenceId" + | "ControllerApprovalId" + | "EventIntentApprovalId" + | "CheckpointId" + | "RecoveryId" + | "GuardianGrantId" + | "ForkId" + | "CryptoSuiteId" + | "CryptoMigrationId" + | "CryptoStateId" + | "ApplicationId" + | "ApplicationEventId" + | "GroupId" + | "GroupKeyWrapId" => validate_id_type!( + vector.wire_type.as_str(), + bytes, + &vector.name, + GenesisAnchor, + AccountId, + ControllerId, + ControllerKeyId, + ControlPolicyId, + RecoveryPolicyId, + ProviderId, + ProviderLogId, + ProviderPolicyId, + DeviceId, + CapabilityGrantId, + DelegationId, + ProposalId, + EventId, + EventAuthorizationId, + AdmissionEvidenceId, + ControllerApprovalId, + EventIntentApprovalId, + CheckpointId, + RecoveryId, + GuardianGrantId, + ForkId, + CryptoSuiteId, + CryptoMigrationId, + CryptoStateId, + ApplicationId, + ApplicationEventId, + GroupId, + GroupKeyWrapId, + ), + "AccountGenesis" => validate_canonical::(bytes, &vector.name), + "AccountOperation" => validate_canonical::(bytes, &vector.name), + "EventBody" => validate_canonical::(bytes, &vector.name), + "AdmissionEvidence" => validate_canonical::(bytes, &vector.name), + "SignedControllerApproval" => { + validate_canonical::(bytes, &vector.name) + } + "AuthorizedEvent" => validate_canonical::(bytes, &vector.name), + "SignedCheckpoint" => validate_canonical::(bytes, &vector.name), + "BackupAuthorityBundle" => validate_canonical::(bytes, &vector.name), + "BackupEnvelope" => validate_canonical::(bytes, &vector.name), + "RecoveryProposal" => validate_canonical::(bytes, &vector.name), + "BeginRecovery" => validate_canonical::(bytes, &vector.name), + "VetoRecovery" => validate_canonical::(bytes, &vector.name), + "CancelRecovery" => validate_canonical::(bytes, &vector.name), + "FinalizeRecovery" => validate_canonical::(bytes, &vector.name), + "GuardianApprovalBody" => validate_canonical::(bytes, &vector.name), + "SignedGuardianApproval" => { + validate_canonical::(bytes, &vector.name) + } + "GuardianApprovalSet" => validate_canonical::(bytes, &vector.name), + "RecoveryThresholdEvidence" => { + validate_canonical::(bytes, &vector.name) + } + "BeginCryptoMigration" => validate_canonical::(bytes, &vector.name), + "ControllerKeyBindingProof" => { + validate_canonical::(bytes, &vector.name) + } + "EventIntentApprovalBody" => { + validate_canonical::(bytes, &vector.name) + } + "SignedEventIntentApproval" => { + validate_canonical::(bytes, &vector.name) + } + "EventIntentApprovals" => validate_canonical::(bytes, &vector.name), + "ControllerApprovalBody" => { + validate_canonical::(bytes, &vector.name) + } + "ControllerApprovals" => validate_canonical::(bytes, &vector.name), + "RecoveryAuthorityPlan" => validate_canonical::(bytes, &vector.name), + "RecoveryDelayAnchor" => validate_canonical::(bytes, &vector.name), + "ForkDescriptor" => validate_canonical::(bytes, &vector.name), + "CapabilityGrant" => validate_canonical::(bytes, &vector.name), + "CapabilityRoot" => validate_canonical::(bytes, &vector.name), + "DelegationBody" => validate_canonical::(bytes, &vector.name), + "SignedDelegation" => validate_canonical::(bytes, &vector.name), + "DelegationChain" => validate_canonical::(bytes, &vector.name), + "ApplicationEventBody" => validate_canonical::(bytes, &vector.name), + "SignedApplicationEvent" => { + validate_canonical::(bytes, &vector.name) + } + "GroupKeyWrapHeader" => validate_canonical::(bytes, &vector.name), + "WrappedGroupKey" => validate_canonical::(bytes, &vector.name), + "RecipientKeyWraps" => validate_canonical::(bytes, &vector.name), + "SocialAttestationBody" => validate_canonical::(bytes, &vector.name), + "SignedSocialAttestation" => { + validate_canonical::(bytes, &vector.name) + } + "NameClaimBody" => validate_canonical::(bytes, &vector.name), + "SignedNameClaim" => validate_canonical::(bytes, &vector.name), + "PrivateArtifactContext" => { + validate_canonical::(bytes, &vector.name) + } + "PrivateMetadataEnvelope" => { + validate_canonical::(bytes, &vector.name) + } + "PortableCredentialBody" => { + validate_canonical::(bytes, &vector.name) + } + "SignedPortableCredential" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderLogEntryBody" => validate_canonical::(bytes, &vector.name), + "ProviderHeadBody" => validate_canonical::(bytes, &vector.name), + "SignedProviderHead" => validate_canonical::(bytes, &vector.name), + "InclusionReceipt" => validate_canonical::(bytes, &vector.name), + "ProviderReceipts" => validate_canonical::(bytes, &vector.name), + "ProviderEquivocationEvidence" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderExportComponent" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderExportComponentDescriptor" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderGenerationExportChunk" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderAuditExportChunk" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderGenerationExportManifest" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderAuditExportManifest" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderRecoveryExportManifest" => { + validate_canonical::(bytes, &vector.name) + } + "ProviderCompactionManifest" => { + validate_canonical::(bytes, &vector.name) + } + "OpaqueProviderAnchorCommitment" => { + validate_canonical::(bytes, &vector.name) + } + "MerkleSetKey" => validate_canonical::(bytes, &vector.name), + "MerkleSetLeaf" => validate_canonical::(bytes, &vector.name), + "MerkleInclusionProof" => validate_canonical::(bytes, &vector.name), + "MerkleConsistencyProof" => { + validate_canonical::(bytes, &vector.name) + } + "MerkleNonMembershipProof" => { + validate_canonical::(bytes, &vector.name) + } + "PairingTicket" => validate_canonical::(bytes, &vector.name), + "PairingTranscript" => validate_canonical::(bytes, &vector.name), + "PairingPossessionProof" => { + validate_canonical::(bytes, &vector.name) + } + "PairingConfirmationContext" => { + validate_canonical::(bytes, &vector.name) + } + "DeviceAuthorizationProposal" => { + validate_canonical::(bytes, &vector.name) + } + "DevicePresenceChallenge" => { + validate_canonical::(bytes, &vector.name) + } + "PresenceProof" => validate_canonical::(bytes, &vector.name), + "SyncRequest" => validate_canonical::(bytes, &vector.name), + "SyncCursor" => validate_canonical::(bytes, &vector.name), + "SyncFrame" => validate_canonical::(bytes, &vector.name), + "SyncResponse" => validate_canonical::(bytes, &vector.name), + "EndpointAuthorizationRequest" => { + validate_canonical::(bytes, &vector.name) + } + "AuthorizedSyncRequest" => validate_canonical::(bytes, &vector.name), + "AuthorizedProposalRequest" => { + validate_canonical::(bytes, &vector.name) + } + "AuthorizedCheckpointRequest" => { + validate_canonical::(bytes, &vector.name) + } + "IdentityProtocolAck" => validate_canonical::(bytes, &vector.name), + "IdentityProtocolReply" => validate_canonical::(bytes, &vector.name), + other => panic!("unhandled interop wire type {other} in {}", vector.name), + } +} + +fn validate_source_owned_variant(vector: &VectorMetadata, bytes: &[u8]) { + if let Some(code) = vector.name.strip_prefix("account-operation-") { + let expected_code = code.parse::().unwrap(); + let operation = AccountOperation::from_canonical_bytes(bytes).unwrap(); + assert_eq!( + operation.kind().code(), + expected_code, + "{} must retain its source-owned operation variant", + vector.name + ); + } + match vector.name.as_str() { + "checkpoint-direct" => { + let checkpoint = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + assert_eq!(checkpoint.body().lifecycle(), AccountLifecycle::Active); + assert!(checkpoint.authorization().controller_approvals().is_some()); + assert!(checkpoint.authorization().transition_witness().is_none()); + } + "checkpoint-migration-pending" => { + let checkpoint = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + assert_eq!( + checkpoint.body().lifecycle(), + AccountLifecycle::MigrationPending + ); + assert!(checkpoint.authorization().controller_approvals().is_some()); + assert!(checkpoint.authorization().transition_witness().is_none()); + } + "checkpoint-migration-dual" => { + let checkpoint = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + assert_eq!( + checkpoint.body().lifecycle(), + AccountLifecycle::MigrationDual + ); + assert!(checkpoint.authorization().controller_approvals().is_some()); + assert!(checkpoint.authorization().transition_witness().is_none()); + } + "checkpoint-transition-finalize" => { + let checkpoint = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + assert_eq!(checkpoint.body().lifecycle(), AccountLifecycle::Active); + assert!(checkpoint.authorization().controller_approvals().is_none()); + assert_eq!( + checkpoint + .authorization() + .transition_witness() + .map(TransitionCheckpointWitness::transition_kind), + Some(CheckpointTransitionKind::FinalizeRecovery) + ); + } + "checkpoint-transition-retire" => { + let checkpoint = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + assert_eq!(checkpoint.body().lifecycle(), AccountLifecycle::Retired); + assert!(checkpoint.authorization().controller_approvals().is_none()); + assert_eq!( + checkpoint + .authorization() + .transition_witness() + .map(TransitionCheckpointWitness::transition_kind), + Some(CheckpointTransitionKind::RetireAccount) + ); + } + "sync-response-frame" => assert!( + SyncResponse::from_canonical_bytes(bytes) + .unwrap() + .as_frame() + .is_some() + ), + "sync-response-complete" => assert!( + SyncResponse::from_canonical_bytes(bytes) + .unwrap() + .as_complete() + .is_some() + ), + "identity-protocol-reply-ack" => assert!( + IdentityProtocolReply::from_canonical_bytes(bytes) + .unwrap() + .as_ack() + .is_some() + ), + "identity-protocol-reply-sync" => assert!( + IdentityProtocolReply::from_canonical_bytes(bytes) + .unwrap() + .as_sync() + .is_some() + ), + "provider-export-component" => assert_eq!( + ProviderExportComponent::from_canonical_bytes(bytes).unwrap(), + ProviderExportComponent::CheckpointBundles + ), + _ => {} + } +} + +#[derive(Clone, Copy)] +enum ExactSigningKey { + Controller(ControllerKeyId), + Public(SigningPublicKey), + Provider(ProviderId), +} + +fn metadata_signing_key(key: &KeyMetadata) -> Option { + if key.algorithm != "Ed25519" { + return None; + } + let public = hex::decode(&key.public_key_hex).ok()?.try_into().ok()?; + SigningPublicKey::ed25519(public).ok() +} + +fn metadata_matches_exact_key(key: &KeyMetadata, expected: ExactSigningKey) -> bool { + let Some(public) = metadata_signing_key(key) else { + return false; + }; + match expected { + ExactSigningKey::Controller(expected_id) => { + ControllerKeyId::for_signing_key(&public) == Ok(expected_id) + } + ExactSigningKey::Public(expected_public) => public == expected_public, + ExactSigningKey::Provider(expected_id) => { + ProviderDescriptor::new(public, Extensions::default()) + .and_then(|provider| provider.id()) + == Ok(expected_id) + } + } +} + +fn resolved_signature_binding_for_key( + name: String, + domain: &str, + message: Vec, + signature_bytes: &[u8], + expected_key: Option, + keys: &[KeyMetadata], +) -> SignatureBinding { + let signature = Signature::try_from(signature_bytes).unwrap(); + let matching = keys + .iter() + .filter_map(|key| metadata_signing_key(key).map(|public| (key, public))) + .filter(|(key, _)| { + expected_key.is_none_or(|expected| metadata_matches_exact_key(key, expected)) + }) + .filter(|(_, public)| { + let public: [u8; 32] = public.as_bytes().to_owned(); + PublicKey::from_bytes(&public) + .unwrap() + .verify(&message, &signature) + .is_ok() + }) + .map(|(key, _)| key) + .collect::>(); + assert_eq!( + matching.len(), + 1, + "each decoded signature must resolve to exactly one deterministic signer" + ); + SignatureBinding { + name, + algorithm: "Ed25519".to_owned(), + domain_ascii: domain.to_owned(), + message_hex: hex::encode(message), + signer_key: matching[0].name.clone(), + public_key_hex: matching[0].public_key_hex.clone(), + signature_hex: hex::encode(signature_bytes), + } +} + +fn resolved_signature_binding( + name: String, + domain: &str, + message: Vec, + signature_bytes: &[u8], + keys: &[KeyMetadata], +) -> SignatureBinding { + resolved_signature_binding_for_key(name, domain, message, signature_bytes, None, keys) +} + +fn append_controller_approval_bindings( + bindings: &mut Vec, + approvals: &ControllerApprovals, + keys: &[KeyMetadata], +) { + for approval in approvals.as_slice() { + let message = approval.body().to_canonical_bytes().unwrap(); + for signature in approval.signatures() { + bindings.push(resolved_signature_binding_for_key( + format!("signature-{}", bindings.len() + 1), + "KRIKOS-ID/controller-approval-signature/v1", + message.clone(), + signature.signature().as_bytes(), + Some(ExactSigningKey::Controller(signature.controller_key_id())), + keys, + )); + } + } +} + +fn append_guardian_bindings( + bindings: &mut Vec, + approvals: &GuardianApprovalSet, + keys: &[KeyMetadata], +) { + for approval in approvals.as_slice() { + bindings.push(resolved_signature_binding_for_key( + format!("signature-{}", bindings.len() + 1), + "KRIKOS-ID/guardian-approval-signature/v1", + approval.body().signing_bytes().unwrap(), + approval.signature().as_bytes(), + Some(ExactSigningKey::Public( + approval.opening().grant().guardian_signing_key(), + )), + keys, + )); + } +} + +fn append_provider_head_binding( + bindings: &mut Vec, + head: &SignedProviderHead, + keys: &[KeyMetadata], +) { + bindings.push(resolved_signature_binding_for_key( + format!("signature-{}", bindings.len() + 1), + "KRIKOS-ID/provider-head-signature/v1", + head.body().signing_bytes().unwrap(), + head.signature().as_bytes(), + Some(ExactSigningKey::Provider(head.body().provider_id())), + keys, + )); +} + +fn append_event_intent_bindings( + bindings: &mut Vec, + approvals: &EventIntentApprovals, + keys: &[KeyMetadata], +) { + for approval in approvals.as_slice() { + let message = approval.body().to_canonical_bytes().unwrap(); + for signature in approval.signatures() { + bindings.push(resolved_signature_binding_for_key( + format!("signature-{}", bindings.len() + 1), + "KRIKOS-ID/event-intent-approval-signature/v1", + message.clone(), + signature.signature().as_bytes(), + Some(ExactSigningKey::Controller(signature.controller_key_id())), + keys, + )); + } + } +} + +fn append_provider_receipt_bindings( + bindings: &mut Vec, + receipts: &ProviderReceipts, + keys: &[KeyMetadata], +) { + for receipt in receipts.as_slice() { + append_provider_head_binding(bindings, receipt.signed_head(), keys); + } +} + +fn append_recovery_threshold_evidence_bindings( + bindings: &mut Vec, + evidence: &RecoveryThresholdEvidence, + keys: &[KeyMetadata], +) { + if let Some(approvals) = evidence.as_guardian_approvals() { + append_guardian_bindings(bindings, approvals, keys); + } +} + +fn append_recovery_delay_anchor_bindings( + bindings: &mut Vec, + anchor: &RecoveryDelayAnchor, + keys: &[KeyMetadata], +) { + append_provider_receipt_bindings(bindings, anchor.receipts(), keys); +} + +fn append_crypto_migration_bindings( + bindings: &mut Vec, + begin: &BeginCryptoMigration, + keys: &[KeyMetadata], +) { + let migration_id = begin.migration().crypto_migration_id().unwrap(); + let message = migration_id.to_canonical_bytes().unwrap(); + for (binding, proof) in begin + .migration() + .bindings() + .iter() + .zip(begin.proofs().as_slice()) + { + assert_eq!(binding.controller_id(), proof.controller_id()); + assert_eq!(proof.migration_id(), migration_id); + assert_eq!( + proof.old_key_signature().algorithm_code(), + SignatureAlgorithm::Ed25519.code() + ); + bindings.push(resolved_signature_binding_for_key( + format!("signature-{}", bindings.len() + 1), + "none", + message.clone(), + proof.old_key_signature().as_bytes(), + Some(ExactSigningKey::Controller(binding.old_key_id())), + keys, + )); + + assert_eq!( + binding.new_signing_key().algorithm_code(), + SignatureAlgorithm::Ed25519.code() + ); + assert_eq!( + proof.new_key_signature().algorithm_code(), + SignatureAlgorithm::Ed25519.code() + ); + let new_public: [u8; 32] = binding.new_signing_key().as_bytes().try_into().unwrap(); + bindings.push(resolved_signature_binding_for_key( + format!("signature-{}", bindings.len() + 1), + "none", + message.clone(), + proof.new_key_signature().as_bytes(), + Some(ExactSigningKey::Public( + SigningPublicKey::ed25519(new_public).unwrap(), + )), + keys, + )); + } +} + +fn append_account_operation_bindings( + bindings: &mut Vec, + operation: &AccountOperation, + keys: &[KeyMetadata], +) { + match operation { + AccountOperation::BeginRecovery(begin) => { + append_recovery_threshold_evidence_bindings(bindings, begin.threshold_evidence(), keys); + } + AccountOperation::CancelRecovery(cancel) => { + append_recovery_threshold_evidence_bindings( + bindings, + cancel.threshold_evidence(), + keys, + ); + } + AccountOperation::FinalizeRecovery(finalize) => { + append_recovery_delay_anchor_bindings(bindings, finalize.delay_anchor(), keys); + } + AccountOperation::BeginCryptoMigration(begin) => { + append_crypto_migration_bindings(bindings, begin, keys); + } + AccountOperation::AuthorizeDevice(_) + | AccountOperation::UpdateDeviceAuthorization(_) + | AccountOperation::UpdateDeviceMetadata(_) + | AccountOperation::SuspendDevice(_) + | AccountOperation::ReinstateDevice(_) + | AccountOperation::RevokeDevice(_) + | AccountOperation::RotateDeviceKeys(_) + | AccountOperation::AddController(_) + | AccountOperation::RemoveController(_) + | AccountOperation::ChangeControlPolicy(_) + | AccountOperation::ChangeRecoveryPolicy(_) + | AccountOperation::ChangeProviderPolicy(_) + | AccountOperation::VetoRecovery(_) + | AccountOperation::ResolveFork(_) + | AccountOperation::ActivateCryptoMigration(_) + | AccountOperation::RetireCryptoSuite(_) + | AccountOperation::UpgradeProtocol(_) + | AccountOperation::RetireAccount(_) => {} + } +} + +fn append_admission_bindings( + bindings: &mut Vec, + evidence: &AdmissionEvidence, + keys: &[KeyMetadata], +) { + if let Some(receipts) = evidence.freshness().provider_receipts() { + append_provider_receipt_bindings(bindings, receipts, keys); + } + if let Some(approvals) = evidence.delay().intent_approvals() { + append_event_intent_bindings(bindings, approvals, keys); + } + if let Some(receipts) = evidence.delay().provider_receipts() { + append_provider_receipt_bindings(bindings, receipts, keys); + } +} + +fn append_authorized_event_bindings( + bindings: &mut Vec, + event: &AuthorizedEvent, + keys: &[KeyMetadata], +) { + append_account_operation_bindings(bindings, event.body().operation(), keys); + append_admission_bindings(bindings, event.admission_evidence(), keys); + append_controller_approval_bindings(bindings, event.approvals(), keys); +} + +fn append_checkpoint_bindings( + bindings: &mut Vec, + checkpoint: &SignedCheckpoint, + keys: &[KeyMetadata], +) { + if let Some(approvals) = checkpoint.authorization().controller_approvals() { + append_controller_approval_bindings(bindings, approvals, keys); + } +} + +fn append_provider_generation_manifest_bindings( + bindings: &mut Vec, + manifest: &ProviderGenerationExportManifest, + keys: &[KeyMetadata], +) { + if let Some(head) = manifest.latest_head() { + append_provider_head_binding(bindings, head, keys); + } +} + +fn append_provider_audit_manifest_bindings( + bindings: &mut Vec, + manifest: &ProviderAuditExportManifest, + keys: &[KeyMetadata], +) { + if let Some(head) = manifest.latest_head() { + append_provider_head_binding(bindings, head, keys); + } + if let Some(evidence) = manifest.equivocation_evidence() { + append_provider_head_binding(bindings, evidence.first(), keys); + append_provider_head_binding(bindings, evidence.second(), keys); + } +} + +#[derive(Deserialize)] +struct GenerationChunkMirror { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + generation_commitment: Digest, + component_code: u16, + ordinal: u32, + start_index: u64, + end_index: u64, + item_payload_bytes: u64, + payload: Vec, +} + +#[derive(Deserialize)] +struct ProviderCheckpointBundleMirror { + genesis: Option, + prior_checkpoint_id: Option, + events: Vec, + checkpoint: SignedCheckpoint, + transition_event: Option, +} + +#[derive(Deserialize)] +struct ProviderCheckpointBundleItemMirror { + format_version: u16, + bundle: ProviderCheckpointBundleMirror, +} + +#[derive(Deserialize)] +struct AuditChunkMirror { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + audit_commitment: Digest, + ordinal: u32, + start_sequence: u64, + end_sequence: u64, + item_payload_bytes: u64, + payload: Vec, +} + +#[derive(Deserialize)] +struct ProviderAuditRecordMirror { + sequence: u64, + head: SignedProviderHead, + consistency_proof: Option, + status_code: u16, +} + +#[derive(Deserialize)] +struct ProviderAuditRecordItemMirror { + format_version: u16, + record: ProviderAuditRecordMirror, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +struct BackupKdfParametersMirror { + algorithm_code: u16, + version: u32, + memory_kib: u32, + iterations: u32, + lanes: u32, + output_bytes: u32, +} + +#[derive(Deserialize)] +struct BackupEnvelopeMirror { + protocol_version: ProtocolVersion, + artifact_kind_code: u16, + password_kdf: BackupKdfParametersMirror, + wrapping_aead: AeadAlgorithm, + content_aead: AeadAlgorithm, + context: PrivateArtifactContext, + salt: [u8; 16], + wrapping_nonce: [u8; 24], + content_nonce: [u8; 24], + wrapped_content_key: Vec, + ciphertext: Vec, + extensions: Extensions, +} + +#[derive(Deserialize)] +struct BackupPayloadMirror { + protocol_version: ProtocolVersion, + authority_bundle: BackupAuthorityBundle, + application_data: Option>, + extensions: Extensions, +} + +fn private_artifact_domain_message(domain: &[u8], body: &[u8]) -> Vec { + let mut message = Vec::with_capacity(domain.len().saturating_add(1).saturating_add(body.len())); + message.extend_from_slice(domain); + message.push(0); + message.extend_from_slice(body); + message +} + +fn backup_envelope_authority_bundle(bytes: &[u8]) -> BackupAuthorityBundle { + const BACKUP_KDF: BackupKdfParametersMirror = BackupKdfParametersMirror { + algorithm_code: 1, + version: 0x13, + memory_kib: 19_456, + iterations: 2, + lanes: 1, + output_bytes: 32, + }; + const WRAP_DOMAIN: &[u8] = b"KRIKOS-ID/private-artifact-key-wrap/v1"; + const CONTENT_DOMAIN: &[u8] = b"KRIKOS-ID/private-artifact-content/v1"; + const PASSPHRASE: &[u8] = b"correct horse battery staple"; + + let envelope: BackupEnvelopeMirror = postcard::from_bytes(bytes).unwrap(); + assert_eq!(envelope.protocol_version, ProtocolVersion::V1); + assert_eq!(envelope.artifact_kind_code, 2); + assert_eq!(envelope.password_kdf, BACKUP_KDF); + assert_eq!(envelope.wrapping_aead, AeadAlgorithm::XChaCha20Poly1305); + assert_eq!(envelope.content_aead, AeadAlgorithm::XChaCha20Poly1305); + assert_eq!(envelope.wrapped_content_key.len(), 48); + + let header = postcard::to_stdvec(&( + envelope.protocol_version, + envelope.artifact_kind_code, + envelope.password_kdf, + envelope.wrapping_aead, + envelope.content_aead, + &envelope.context, + envelope.salt, + envelope.wrapping_nonce, + envelope.content_nonce, + &envelope.extensions, + )) + .unwrap(); + let parameters = Params::new( + BACKUP_KDF.memory_kib, + BACKUP_KDF.iterations, + BACKUP_KDF.lanes, + Some(BACKUP_KDF.output_bytes as usize), + ) + .unwrap(); + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, parameters); + let mut wrapping_key = [0_u8; 32]; + argon2 + .hash_password_into(PASSPHRASE, &envelope.salt, &mut wrapping_key) + .unwrap(); + let wrapping_aad = private_artifact_domain_message(WRAP_DOMAIN, &header); + let wrapped_key_cipher = XChaCha20Poly1305::new(&Key::from(wrapping_key)); + let content_key = wrapped_key_cipher + .decrypt( + &XNonce::from(envelope.wrapping_nonce), + Payload { + msg: &envelope.wrapped_content_key, + aad: &wrapping_aad, + }, + ) + .unwrap(); + let content_key: [u8; 32] = content_key.try_into().unwrap(); + let content_aad_body = + postcard::to_stdvec(&(header, envelope.wrapped_content_key.as_slice())).unwrap(); + let content_aad = private_artifact_domain_message(CONTENT_DOMAIN, &content_aad_body); + let content_cipher = XChaCha20Poly1305::new(&Key::from(content_key)); + let plaintext = content_cipher + .decrypt( + &XNonce::from(envelope.content_nonce), + Payload { + msg: &envelope.ciphertext, + aad: &content_aad, + }, + ) + .unwrap(); + let payload: BackupPayloadMirror = postcard::from_bytes(&plaintext).unwrap(); + assert_eq!(payload.protocol_version, ProtocolVersion::V1); + assert!(payload.application_data.is_none()); + assert_eq!(payload.extensions, Extensions::default()); + payload.authority_bundle +} + +fn chunk_items(payload: &[u8]) -> Vec> { + postcard::from_bytes(payload).expect("validated provider chunk payload must decode") +} + +fn append_provider_generation_chunk_bindings( + bindings: &mut Vec, + chunk: &ProviderGenerationExportChunk, + bytes: &[u8], + keys: &[KeyMetadata], +) { + let mirror: GenerationChunkMirror = postcard::from_bytes(bytes).unwrap(); + assert_eq!(mirror.format_version, 1); + assert_eq!(mirror.provider_id, chunk.provider_id()); + assert_eq!(mirror.log_id, chunk.log_id()); + assert_eq!(mirror.key_version, chunk.key_version()); + assert_eq!(mirror.generation_commitment, chunk.generation_commitment()); + assert_eq!(mirror.component_code, chunk.component().unwrap().code()); + assert_eq!(mirror.ordinal, chunk.ordinal()); + assert_eq!(mirror.start_index, chunk.start_index()); + assert_eq!(mirror.end_index, chunk.end_index()); + assert_eq!(mirror.item_payload_bytes, chunk.item_payload_bytes()); + + for item in chunk_items(&mirror.payload) { + match chunk.component().unwrap() { + ProviderExportComponent::Receipts => { + let receipt = InclusionReceipt::from_canonical_bytes(&item).unwrap(); + append_provider_head_binding(bindings, receipt.signed_head(), keys); + } + ProviderExportComponent::CheckpointBundles => { + let item: ProviderCheckpointBundleItemMirror = postcard::from_bytes(&item).unwrap(); + assert_eq!(item.format_version, 1); + let _lineage_shape = (item.bundle.genesis, item.bundle.prior_checkpoint_id); + for event in &item.bundle.events { + append_authorized_event_bindings(bindings, event, keys); + } + append_checkpoint_bindings(bindings, &item.bundle.checkpoint, keys); + if let Some(event) = &item.bundle.transition_event { + append_authorized_event_bindings(bindings, event, keys); + } + } + ProviderExportComponent::Entries + | ProviderExportComponent::LeafHashes + | ProviderExportComponent::CompactionManifests => {} + } + } +} + +fn append_provider_audit_chunk_bindings( + bindings: &mut Vec, + chunk: &ProviderAuditExportChunk, + bytes: &[u8], + keys: &[KeyMetadata], +) { + let mirror: AuditChunkMirror = postcard::from_bytes(bytes).unwrap(); + assert_eq!(mirror.format_version, 1); + assert_eq!(mirror.provider_id, chunk.provider_id()); + assert_eq!(mirror.log_id, chunk.log_id()); + assert_eq!(mirror.audit_commitment, chunk.audit_commitment()); + assert_eq!(mirror.ordinal, chunk.ordinal()); + assert_eq!(mirror.start_sequence, chunk.start_sequence()); + assert_eq!(mirror.end_sequence, chunk.end_sequence()); + assert_eq!(mirror.item_payload_bytes, chunk.item_payload_bytes()); + for item in chunk_items(&mirror.payload) { + let item: ProviderAuditRecordItemMirror = postcard::from_bytes(&item).unwrap(); + assert_eq!(item.format_version, 1); + let _authenticated_record_shape = ( + item.record.sequence, + item.record.consistency_proof, + item.record.status_code, + ); + append_provider_head_binding(bindings, &item.record.head, keys); + } +} + +fn append_sync_frame_bindings( + bindings: &mut Vec, + frame: &SyncFrame, + keys: &[KeyMetadata], +) { + for event in frame.events() { + append_authorized_event_bindings(bindings, event, keys); + } +} + +fn append_sync_response_bindings( + bindings: &mut Vec, + response: &SyncResponse, + keys: &[KeyMetadata], +) { + if let Some(frame) = response.as_frame() { + append_sync_frame_bindings(bindings, frame, keys); + } +} + +fn append_identity_reply_bindings( + bindings: &mut Vec, + reply: &IdentityProtocolReply, + keys: &[KeyMetadata], +) { + if let Some(response) = reply.as_sync() { + append_sync_response_bindings(bindings, response, keys); + } +} + +fn append_backup_bundle_bindings( + bindings: &mut Vec, + bundle: &BackupAuthorityBundle, + keys: &[KeyMetadata], +) { + for event in bundle.events() { + append_authorized_event_bindings(bindings, event, keys); + } + append_checkpoint_bindings(bindings, bundle.checkpoint(), keys); +} + +fn append_provider_recovery_manifest_bindings( + bindings: &mut Vec, + manifest: &ProviderRecoveryExportManifest, + keys: &[KeyMetadata], +) { + append_provider_generation_manifest_bindings(bindings, manifest.generation(), keys); + append_provider_audit_manifest_bindings(bindings, manifest.audit(), keys); +} + +fn append_checkpoint_request_bindings( + bindings: &mut Vec, + request: &AuthorizedCheckpointRequest, + keys: &[KeyMetadata], +) { + append_checkpoint_bindings(bindings, request.checkpoint(), keys); +} + +fn expected_signature_bindings( + vector: &VectorMetadata, + bytes: &[u8], + directory: &Path, + vectors: &BTreeMap<&str, &VectorMetadata>, + keys: &[KeyMetadata], +) -> Vec { + let mut bindings = Vec::new(); + match vector.wire_type.as_str() { + "AdmissionEvidence" => append_admission_bindings( + &mut bindings, + &AdmissionEvidence::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "SignedEventIntentApproval" => { + let value = SignedEventIntentApproval::from_canonical_bytes(bytes).unwrap(); + append_event_intent_bindings( + &mut bindings, + &EventIntentApprovals::new(vec![value]).unwrap(), + keys, + ); + } + "EventIntentApprovals" => { + let value = EventIntentApprovals::from_canonical_bytes(bytes).unwrap(); + append_event_intent_bindings(&mut bindings, &value, keys); + } + "SignedControllerApproval" => { + let value = SignedControllerApproval::from_canonical_bytes(bytes).unwrap(); + let approvals = ControllerApprovals::new(vec![value]).unwrap(); + append_controller_approval_bindings(&mut bindings, &approvals, keys); + } + "ControllerApprovals" => { + let value = ControllerApprovals::from_canonical_bytes(bytes).unwrap(); + append_controller_approval_bindings(&mut bindings, &value, keys); + } + "AccountOperation" => append_account_operation_bindings( + &mut bindings, + &AccountOperation::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "EventBody" => { + let value = EventBody::from_canonical_bytes(bytes).unwrap(); + append_account_operation_bindings(&mut bindings, value.operation(), keys); + } + "AuthorizedEvent" => { + let value = AuthorizedEvent::from_canonical_bytes(bytes).unwrap(); + append_authorized_event_bindings(&mut bindings, &value, keys); + } + "SignedCheckpoint" => { + let value = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + append_checkpoint_bindings(&mut bindings, &value, keys); + } + "BackupAuthorityBundle" => { + let value = BackupAuthorityBundle::from_canonical_bytes(bytes).unwrap(); + append_backup_bundle_bindings(&mut bindings, &value, keys); + } + "SignedGuardianApproval" => { + let value = SignedGuardianApproval::from_canonical_bytes(bytes).unwrap(); + let approvals = GuardianApprovalSet::try_new(vec![value]).unwrap(); + append_guardian_bindings(&mut bindings, &approvals, keys); + } + "GuardianApprovalSet" => { + let value = GuardianApprovalSet::from_canonical_bytes(bytes).unwrap(); + append_guardian_bindings(&mut bindings, &value, keys); + } + "RecoveryThresholdEvidence" => { + let value = RecoveryThresholdEvidence::from_canonical_bytes(bytes).unwrap(); + append_recovery_threshold_evidence_bindings(&mut bindings, &value, keys); + } + "BeginRecovery" => { + let value = BeginRecovery::from_canonical_bytes(bytes).unwrap(); + append_recovery_threshold_evidence_bindings( + &mut bindings, + value.threshold_evidence(), + keys, + ); + } + "CancelRecovery" => { + let value = CancelRecovery::from_canonical_bytes(bytes).unwrap(); + append_recovery_threshold_evidence_bindings( + &mut bindings, + value.threshold_evidence(), + keys, + ); + } + "RecoveryDelayAnchor" => append_recovery_delay_anchor_bindings( + &mut bindings, + &RecoveryDelayAnchor::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "FinalizeRecovery" => { + let value = FinalizeRecovery::from_canonical_bytes(bytes).unwrap(); + append_recovery_delay_anchor_bindings(&mut bindings, value.delay_anchor(), keys); + } + "BeginCryptoMigration" => append_crypto_migration_bindings( + &mut bindings, + &BeginCryptoMigration::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "ControllerKeyBindingProof" => { + assert_eq!(vector.dependencies, ["crypto-migration-begin"]); + let dependency = vectors["crypto-migration-begin"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let begin = BeginCryptoMigration::from_canonical_bytes(&dependency_bytes).unwrap(); + let proof = ControllerKeyBindingProof::from_canonical_bytes(bytes).unwrap(); + assert_eq!(begin.proofs().as_slice(), std::slice::from_ref(&proof)); + append_crypto_migration_bindings(&mut bindings, &begin, keys); + } + "SignedDelegation" => { + let value = SignedDelegation::from_canonical_bytes(bytes).unwrap(); + bindings.push(resolved_signature_binding( + "signature-1".to_owned(), + "KRIKOS-ID/capability-delegation-signature/v1", + value.body().to_canonical_bytes().unwrap(), + value.signature().as_bytes(), + keys, + )); + } + "DelegationChain" => { + let value = DelegationChain::from_canonical_bytes(bytes).unwrap(); + for link in value.links() { + bindings.push(resolved_signature_binding( + format!("signature-{}", bindings.len() + 1), + "KRIKOS-ID/capability-delegation-signature/v1", + link.body().to_canonical_bytes().unwrap(), + link.signature().as_bytes(), + keys, + )); + } + } + "SignedApplicationEvent" => { + let value = SignedApplicationEvent::from_canonical_bytes(bytes).unwrap(); + bindings.push(resolved_signature_binding( + "signature-1".to_owned(), + "KRIKOS-ID/application-event-signature/v1", + value.body().signing_bytes().unwrap(), + value.signature().as_bytes(), + keys, + )); + } + "SignedSocialAttestation" => { + let value = SignedSocialAttestation::from_canonical_bytes(bytes).unwrap(); + bindings.push(resolved_signature_binding( + "signature-1".to_owned(), + "KRIKOS-ID/social-attestation-signature/v1", + value.body().signing_bytes().unwrap(), + value.issuer_signature().as_bytes(), + keys, + )); + } + "SignedNameClaim" => { + let value = SignedNameClaim::from_canonical_bytes(bytes).unwrap(); + bindings.push(resolved_signature_binding( + "signature-1".to_owned(), + "KRIKOS-ID/name-claim-signature/v1", + value.body().signing_bytes().unwrap(), + value.subject_signature().as_bytes(), + keys, + )); + } + "SignedPortableCredential" => { + let value = SignedPortableCredential::from_canonical_bytes(bytes).unwrap(); + bindings.push(resolved_signature_binding( + "signature-1".to_owned(), + "KRIKOS-ID/portable-credential-signature/v1", + value.body().signing_bytes().unwrap(), + value.issuer_signature().as_bytes(), + keys, + )); + } + "SignedProviderHead" => append_provider_head_binding( + &mut bindings, + &SignedProviderHead::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "InclusionReceipt" => append_provider_head_binding( + &mut bindings, + InclusionReceipt::from_canonical_bytes(bytes) + .unwrap() + .signed_head(), + keys, + ), + "ProviderReceipts" => { + let value = ProviderReceipts::from_canonical_bytes(bytes).unwrap(); + append_provider_receipt_bindings(&mut bindings, &value, keys); + } + "ProviderEquivocationEvidence" => { + let value = ProviderEquivocationEvidence::from_canonical_bytes(bytes).unwrap(); + append_provider_head_binding(&mut bindings, value.first(), keys); + append_provider_head_binding(&mut bindings, value.second(), keys); + } + "ProviderGenerationExportChunk" => { + let chunk = ProviderGenerationExportChunk::from_canonical_bytes(bytes).unwrap(); + append_provider_generation_chunk_bindings(&mut bindings, &chunk, bytes, keys); + } + "ProviderAuditExportChunk" => { + let chunk = ProviderAuditExportChunk::from_canonical_bytes(bytes).unwrap(); + append_provider_audit_chunk_bindings(&mut bindings, &chunk, bytes, keys); + } + "ProviderGenerationExportManifest" => { + let manifest = ProviderGenerationExportManifest::from_canonical_bytes(bytes).unwrap(); + append_provider_generation_manifest_bindings(&mut bindings, &manifest, keys); + } + "ProviderAuditExportManifest" => { + let manifest = ProviderAuditExportManifest::from_canonical_bytes(bytes).unwrap(); + append_provider_audit_manifest_bindings(&mut bindings, &manifest, keys); + } + "ProviderRecoveryExportManifest" => { + let manifest = ProviderRecoveryExportManifest::from_canonical_bytes(bytes).unwrap(); + append_provider_recovery_manifest_bindings(&mut bindings, &manifest, keys); + } + "PairingPossessionProof" => { + let value = PairingPossessionProof::from_canonical_bytes(bytes).unwrap(); + let dependency = vectors["pairing-transcript"]; + assert!( + vector + .dependencies + .iter() + .any(|name| name == dependency.name.as_str()) + ); + let transcript = PairingTranscript::from_canonical_bytes( + &fs::read(directory.join(&dependency.canonical_file)).unwrap(), + ) + .unwrap(); + assert_eq!(value.transcript_id(), transcript.transcript_id().unwrap()); + bindings.push(resolved_signature_binding_for_key( + "signature-1".to_owned(), + "KRIKOS-ID/pairing-application-possession/v1", + transcript.application_possession_signing_bytes().unwrap(), + value.application_signature().as_bytes(), + Some(ExactSigningKey::Public( + transcript.proposed_device().application_signing_key(), + )), + keys, + )); + bindings.push(resolved_signature_binding_for_key( + "signature-2".to_owned(), + "KRIKOS-ID/pairing-endpoint-possession/v1", + transcript.endpoint_possession_signing_bytes().unwrap(), + value.endpoint_signature().as_bytes(), + Some(ExactSigningKey::Public( + transcript.proposed_device().endpoint_key().as_signing_key(), + )), + keys, + )); + } + "PresenceProof" => { + let value = PresenceProof::from_canonical_bytes(bytes).unwrap(); + bindings.push(resolved_signature_binding( + "signature-1".to_owned(), + "KRIKOS-ID/device-presence-signature/v1", + value.challenge().signing_bytes().unwrap(), + value.signature().as_bytes(), + keys, + )); + } + "SyncFrame" => append_sync_frame_bindings( + &mut bindings, + &SyncFrame::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "SyncResponse" => append_sync_response_bindings( + &mut bindings, + &SyncResponse::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "AuthorizedCheckpointRequest" => append_checkpoint_request_bindings( + &mut bindings, + &AuthorizedCheckpointRequest::from_canonical_bytes(bytes).unwrap(), + keys, + ), + "IdentityProtocolReply" => append_identity_reply_bindings( + &mut bindings, + &IdentityProtocolReply::from_canonical_bytes(bytes).unwrap(), + keys, + ), + _ => {} + } + bindings +} + +struct PairingMacKeyInputs { + secret_seed: [u8; 32], + subject_public_key: AgreementPublicKey, + connection_public_key: AgreementPublicKey, +} + +fn expected_pairing_mac_binding( + name: &str, + key_context: &str, + message_domain: &str, + key_inputs: PairingMacKeyInputs, + transcript_bytes: &[u8], + expected_mac: &[u8; 32], +) -> MacBinding { + let secret = StaticSecret::from(key_inputs.secret_seed); + let connection_public = X25519PublicKey::from(*key_inputs.connection_public_key.as_bytes()); + let shared = secret.diffie_hellman(&connection_public); + let mut material = [0_u8; 96]; + material[..32].copy_from_slice(shared.as_bytes()); + material[32..64].copy_from_slice(key_inputs.subject_public_key.as_bytes()); + material[64..].copy_from_slice(key_inputs.connection_public_key.as_bytes()); + let key = blake3::derive_key(key_context, &material); + let mut message = Vec::with_capacity(message_domain.len() + 1 + transcript_bytes.len()); + message.extend_from_slice(message_domain.as_bytes()); + message.push(0); + message.extend_from_slice(transcript_bytes); + assert_eq!(blake3::keyed_hash(&key, &message).as_bytes(), expected_mac); + MacBinding { + name: name.to_owned(), + algorithm: "BLAKE3 keyed_hash(key, message)".to_owned(), + key_derivation_algorithm: "BLAKE3 derive_key(context, input)".to_owned(), + key_derivation_context_ascii: key_context.to_owned(), + key_derivation_input_hex: hex::encode(material), + message_domain_ascii: message_domain.to_owned(), + message_hex: hex::encode(message), + expected_mac_hex: hex::encode(expected_mac), + } +} + +const INTEROP_SYNC_CURSOR_KEY: [u8; 32] = [0x51; 32]; + +#[derive(Deserialize)] +struct SyncCursorMirror { + protocol_version: ProtocolVersion, + account_id: AccountId, + source_heads: Vec, + next_item: u64, + delivered_bytes: u64, + authenticator: [u8; 32], +} + +fn expected_sync_cursor_mac_binding(name: String, cursor: &SyncCursor) -> MacBinding { + let encoded = cursor.to_canonical_bytes().unwrap(); + let mirror: SyncCursorMirror = postcard::from_bytes(&encoded).unwrap(); + assert_eq!(mirror.protocol_version, ProtocolVersion::V1); + assert_eq!(mirror.account_id, cursor.account_id()); + assert_eq!(mirror.source_heads, cursor.source_heads()); + assert_eq!(mirror.next_item, cursor.next_item()); + assert_eq!(mirror.delivered_bytes, cursor.delivered_bytes()); + let message = postcard::to_stdvec(&( + ProtocolVersion::V1, + cursor.account_id(), + cursor.source_heads(), + cursor.next_item(), + cursor.delivered_bytes(), + )) + .unwrap(); + let expected = blake3::keyed_hash(&INTEROP_SYNC_CURSOR_KEY, &message); + assert_eq!(expected.as_bytes(), &mirror.authenticator); + assert!( + cursor + .verify(&CursorKey::new(INTEROP_SYNC_CURSOR_KEY).unwrap()) + .is_ok() + ); + MacBinding { + name, + algorithm: "BLAKE3 keyed_hash(key, message)".to_owned(), + key_derivation_algorithm: "raw 256-bit test key".to_owned(), + key_derivation_context_ascii: "none".to_owned(), + key_derivation_input_hex: hex::encode(INTEROP_SYNC_CURSOR_KEY), + message_domain_ascii: "none".to_owned(), + message_hex: hex::encode(message), + expected_mac_hex: hex::encode(mirror.authenticator), + } +} + +fn append_sync_cursor_mac_binding(bindings: &mut Vec, cursor: Option<&SyncCursor>) { + if let Some(cursor) = cursor { + bindings.push(expected_sync_cursor_mac_binding( + format!("cursor-authenticator-{}", bindings.len() + 1), + cursor, + )); + } +} + +fn append_sync_response_mac_bindings(bindings: &mut Vec, response: &SyncResponse) { + if let Some(frame) = response.as_frame() { + append_sync_cursor_mac_binding(bindings, frame.continuation()); + } +} + +fn expected_mac_bindings( + vector: &VectorMetadata, + bytes: &[u8], + directory: &Path, + vectors: &BTreeMap<&str, &VectorMetadata>, + keys: &[KeyMetadata], +) -> Vec { + let mut bindings = Vec::new(); + match vector.wire_type.as_str() { + "PairingPossessionProof" => { + let seed = |name: &str| -> [u8; 32] { + let key = keys.iter().find(|key| key.name == name).unwrap(); + assert_eq!(key.algorithm, "X25519"); + hex::decode(&key.test_only_secret_seed_hex) + .unwrap() + .try_into() + .unwrap() + }; + let proof = PairingPossessionProof::from_canonical_bytes(bytes).unwrap(); + let dependency = vectors["pairing-transcript"]; + let transcript = PairingTranscript::from_canonical_bytes( + &fs::read(directory.join(&dependency.canonical_file)).unwrap(), + ) + .unwrap(); + let transcript_bytes = transcript.to_canonical_bytes().unwrap(); + bindings.extend([ + expected_pairing_mac_binding( + "agreement-possession", + "KRIKOS-ID/pairing-agreement-proof-key/v1", + "KRIKOS-ID/pairing-agreement-possession/v1", + PairingMacKeyInputs { + secret_seed: seed("pairing-proposed-agreement"), + subject_public_key: transcript.proposed_device().agreement_key(), + connection_public_key: transcript.connection_ephemeral_public_key(), + }, + &transcript_bytes, + proof.agreement_mac(), + ), + expected_pairing_mac_binding( + "pairing-ephemeral-possession", + "KRIKOS-ID/pairing-ephemeral-proof-key/v1", + "KRIKOS-ID/pairing-ephemeral-possession/v1", + PairingMacKeyInputs { + secret_seed: seed("pairing-ticket-ephemeral"), + subject_public_key: transcript.pairing_ephemeral_public_key(), + connection_public_key: transcript.connection_ephemeral_public_key(), + }, + &transcript_bytes, + proof.pairing_ephemeral_mac(), + ), + ]); + } + "SyncCursor" => append_sync_cursor_mac_binding( + &mut bindings, + Some(&SyncCursor::from_canonical_bytes(bytes).unwrap()), + ), + "SyncRequest" => append_sync_cursor_mac_binding( + &mut bindings, + SyncRequest::from_canonical_bytes(bytes) + .unwrap() + .continuation(), + ), + "SyncFrame" => append_sync_cursor_mac_binding( + &mut bindings, + SyncFrame::from_canonical_bytes(bytes) + .unwrap() + .continuation(), + ), + "SyncResponse" => append_sync_response_mac_bindings( + &mut bindings, + &SyncResponse::from_canonical_bytes(bytes).unwrap(), + ), + "AuthorizedSyncRequest" => append_sync_cursor_mac_binding( + &mut bindings, + AuthorizedSyncRequest::from_canonical_bytes(bytes) + .unwrap() + .request() + .continuation(), + ), + "IdentityProtocolReply" => { + let reply = IdentityProtocolReply::from_canonical_bytes(bytes).unwrap(); + if let Some(response) = reply.as_sync() { + append_sync_response_mac_bindings(&mut bindings, response); + } + } + _ => {} + } + bindings +} + +fn validate_signature_bindings( + vector: &VectorMetadata, + bytes: &[u8], + directory: &Path, + vectors: &BTreeMap<&str, &VectorMetadata>, + keys: &[KeyMetadata], +) { + assert_eq!( + vector.signature_bindings, + expected_signature_bindings(vector, bytes, directory, vectors, keys), + "{} signature bindings must recursively describe its decoded canonical object", + vector.name + ); +} + +fn validate_mac_bindings( + vector: &VectorMetadata, + bytes: &[u8], + directory: &Path, + vectors: &BTreeMap<&str, &VectorMetadata>, + keys: &[KeyMetadata], +) { + assert_eq!( + vector.mac_bindings, + expected_mac_bindings(vector, bytes, directory, vectors, keys), + "{} MAC bindings must recursively describe its decoded canonical object", + vector.name + ); +} + +fn derivation( + output_name: &str, + algorithm: &str, + domain: &str, + message: Vec, + digest: &Digest, +) -> DerivationMetadata { + DerivationMetadata { + output_name: output_name.to_owned(), + algorithm: algorithm.to_owned(), + domain_or_context_ascii: domain.to_owned(), + message_hex: hex::encode(message), + expected_output_hex: hex::encode(digest.as_bytes()), + } +} + +fn domain_derivation( + output_name: &str, + domain: &str, + message: Vec, + digest: &Digest, +) -> DerivationMetadata { + derivation( + output_name, + "BLAKE3-256(domain || 0x00 || message)", + domain, + message, + digest, + ) +} + +fn derive_key_derivation( + output_name: &str, + context: &str, + message: Vec, + digest: &Digest, +) -> DerivationMetadata { + derivation( + output_name, + "BLAKE3 derive_key(context, message)", + context, + message, + digest, + ) +} + +fn network_request_commitment_derivation( + ack: &IdentityProtocolAck, + canonical_request: &[u8], +) -> DerivationMetadata { + let mut message = Vec::with_capacity(canonical_request.len().saturating_add(2)); + message.extend_from_slice(&ack.protocol().unwrap().code().to_be_bytes()); + message.extend_from_slice(canonical_request); + derive_key_derivation( + "network_request_commitment", + "KRIKOS-ID/network-request-commitment/v1", + message, + &ack.request_commitment(), + ) +} + +#[derive(Serialize)] +struct ProviderAnchorCommitmentPreimageMirror<'a> { + format_version: u16, + manifest: &'a ProviderCompactionManifest, +} + +fn provider_anchor_commitment_derivation( + anchor: OpaqueProviderAnchorCommitment, + manifest: &ProviderCompactionManifest, +) -> DerivationMetadata { + let message = postcard::to_stdvec(&ProviderAnchorCommitmentPreimageMirror { + format_version: 1, + manifest, + }) + .unwrap(); + domain_derivation( + "provider_anchor_commitment", + "KRIKOS-ID/provider-anchor-commitment/v1", + message, + &anchor.digest(), + ) +} + +#[derive(Serialize)] +struct ProviderChunkListCommitmentMirror<'a> { + format_version: u16, + component_code: u16, + chunk_count: u32, + commitments: &'a [Digest], +} + +fn provider_chunk_list_derivation( + output_name: &str, + domain: &str, + component_code: u16, + commitments: &[Digest], +) -> DerivationMetadata { + let message = postcard::to_stdvec(&ProviderChunkListCommitmentMirror { + format_version: 1, + component_code, + chunk_count: u32::try_from(commitments.len()).unwrap(), + commitments, + }) + .unwrap(); + let mut hasher = blake3::Hasher::new(); + hasher.update(domain.as_bytes()); + hasher.update(&[0]); + hasher.update(&message); + let digest = Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()); + domain_derivation(output_name, domain, message, &digest) +} + +const MERKLE_INTERMEDIATE_OUTPUT_NAMES: [&str; 8] = [ + "merkle_node_1", + "merkle_node_2", + "merkle_node_3", + "merkle_node_4", + "merkle_node_5", + "merkle_node_6", + "merkle_node_7", + "merkle_node_8", +]; + +fn merkle_domain_digest(domain: &str, message: &[u8]) -> Digest { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain.as_bytes()); + hasher.update(&[0]); + hasher.update(message); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) +} + +fn derivation_output_digest(derivation: &DerivationMetadata) -> Digest { + Digest::new( + HashAlgorithm::Blake3_256, + hex::decode(&derivation.expected_output_hex) + .unwrap() + .try_into() + .unwrap(), + ) +} + +fn merkle_leaf_derivation(output_name: &str, leaf: &MerkleSetLeaf) -> DerivationMetadata { + let message = + postcard::to_stdvec(&(leaf.key().type_tag(), leaf.key().id(), leaf.value_hash())).unwrap(); + let digest = merkle_domain_digest("KRIKOS-ID/merkle-leaf/v1", &message); + domain_derivation(output_name, "KRIKOS-ID/merkle-leaf/v1", message, &digest) +} + +fn merkle_node_step(left: Digest, right: Digest) -> (Vec, Digest) { + let message = postcard::to_stdvec(&(left, right)).unwrap(); + let digest = merkle_domain_digest("KRIKOS-ID/merkle-node/v1", &message); + (message, digest) +} + +fn merkle_split(tree_size: u64) -> u64 { + assert!(tree_size > 1); + let mut split = 1_u64; + while split.checked_mul(2).is_some_and(|next| next < tree_size) { + split = split.checked_mul(2).unwrap(); + } + split +} + +fn merkle_inclusion_steps( + leaf_hash: Digest, + leaf_index: u64, + tree_size: u64, + audit_path: &[Digest], + path_index: &mut usize, + steps: &mut Vec<(Vec, Digest)>, +) -> Digest { + if tree_size == 1 { + assert_eq!(leaf_index, 0); + return leaf_hash; + } + let split = merkle_split(tree_size); + let (left, right) = if leaf_index < split { + let left = + merkle_inclusion_steps(leaf_hash, leaf_index, split, audit_path, path_index, steps); + let right = audit_path[*path_index]; + *path_index = path_index.checked_add(1).unwrap(); + (left, right) + } else { + let right = merkle_inclusion_steps( + leaf_hash, + leaf_index - split, + tree_size - split, + audit_path, + path_index, + steps, + ); + let left = audit_path[*path_index]; + *path_index = path_index.checked_add(1).unwrap(); + (left, right) + }; + let step = merkle_node_step(left, right); + let digest = step.1; + steps.push(step); + digest +} + +fn merkle_inclusion_derivations( + leaf: &MerkleSetLeaf, + proof: &MerkleInclusionProof, + leaf_output_name: &str, + root_output_name: &str, +) -> Vec { + let leaf_derivation = merkle_leaf_derivation(leaf_output_name, leaf); + let leaf_hash = Digest::new( + HashAlgorithm::Blake3_256, + hex::decode(&leaf_derivation.expected_output_hex) + .unwrap() + .try_into() + .unwrap(), + ); + let mut path_index = 0_usize; + let mut steps = Vec::new(); + let _root = merkle_inclusion_steps( + leaf_hash, + proof.leaf_index(), + proof.tree_size(), + proof.audit_path(), + &mut path_index, + &mut steps, + ); + assert_eq!(path_index, proof.audit_path().len()); + assert!(!steps.is_empty()); + assert!(steps.len().saturating_sub(1) <= MERKLE_INTERMEDIATE_OUTPUT_NAMES.len()); + let last = steps.len() - 1; + let mut derivations = vec![leaf_derivation]; + derivations.extend( + steps + .into_iter() + .enumerate() + .map(|(index, (message, digest))| { + let output_name = if index == last { + root_output_name + } else { + MERKLE_INTERMEDIATE_OUTPUT_NAMES[index] + }; + domain_derivation(output_name, "KRIKOS-ID/merkle-node/v1", message, &digest) + }), + ); + derivations +} + +fn merkle_consistency_derivations( + old_leaf: &MerkleSetLeaf, + proof: &MerkleConsistencyProof, +) -> Vec { + assert_eq!(proof.old_size(), 1); + assert_eq!(proof.new_size(), 3); + assert_eq!(proof.audit_path().len(), 2); + let old_root = merkle_leaf_derivation("old_merkle_root", old_leaf); + let mut current = Digest::new( + HashAlgorithm::Blake3_256, + hex::decode(&old_root.expected_output_hex) + .unwrap() + .try_into() + .unwrap(), + ); + let mut derivations = vec![old_root]; + for (index, sibling) in proof.audit_path().iter().copied().enumerate() { + let (message, digest) = merkle_node_step(current, sibling); + derivations.push(domain_derivation( + if index + 1 == proof.audit_path().len() { + "new_merkle_root" + } else { + MERKLE_INTERMEDIATE_OUTPUT_NAMES[index] + }, + "KRIKOS-ID/merkle-node/v1", + message, + &digest, + )); + current = digest; + } + derivations +} + +fn merkle_non_membership_derivations(proof: &MerkleNonMembershipProof) -> Vec { + assert!(proof.predecessor().is_none()); + let successor = proof.successor().unwrap(); + merkle_inclusion_derivations( + successor.leaf(), + successor.proof(), + "merkle_neighbor_leaf_hash", + "merkle_root", + ) +} + +fn derivations_for_account_operation(operation: &AccountOperation) -> Vec { + match operation { + AccountOperation::BeginRecovery(begin) => derivations_for_wire_type( + "RecoveryProposal", + &begin.proposal().to_canonical_bytes().unwrap(), + ), + AccountOperation::ResolveFork(resolve) => derivations_for_wire_type( + "ForkDescriptor", + &resolve.fork().to_canonical_bytes().unwrap(), + ), + AccountOperation::BeginCryptoMigration(begin) => { + derivations_for_wire_type("BeginCryptoMigration", &begin.to_canonical_bytes().unwrap()) + } + _ => Vec::new(), + } +} + +fn derivations_for_wire_type(wire_type: &str, bytes: &[u8]) -> Vec { + match wire_type { + "AccountGenesis" => { + let value = AccountGenesis::from_canonical_bytes(bytes).unwrap(); + vec![ + domain_derivation( + "account_id", + "KRIKOS-ID/account-id/v1", + bytes.to_vec(), + value.account_id().unwrap().as_digest(), + ), + domain_derivation( + "genesis_anchor", + "KRIKOS-ID/genesis-anchor/v1", + bytes.to_vec(), + value.genesis_anchor().unwrap().as_digest(), + ), + ] + } + "EventBody" => { + let value = EventBody::from_canonical_bytes(bytes).unwrap(); + let mut derivations = vec![domain_derivation( + "proposal_id", + "KRIKOS-ID/account-proposal/v1", + bytes.to_vec(), + value.proposal_id().unwrap().as_digest(), + )]; + derivations.extend(derivations_for_account_operation(value.operation())); + derivations + } + "AccountOperation" => { + let value = AccountOperation::from_canonical_bytes(bytes).unwrap(); + derivations_for_account_operation(&value) + } + "AdmissionEvidence" => { + let value = AdmissionEvidence::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "admission_evidence_id", + "KRIKOS-ID/admission-evidence/v1", + bytes.to_vec(), + value.admission_evidence_id().unwrap().as_digest(), + )] + } + "AuthorizedEvent" => { + let value = AuthorizedEvent::from_canonical_bytes(bytes).unwrap(); + let evidence_id = value.admission_evidence().admission_evidence_id().unwrap(); + let mut derivations = + derivations_for_wire_type("EventBody", &value.body().to_canonical_bytes().unwrap()); + derivations.extend([ + domain_derivation( + "admission_evidence_id", + "KRIKOS-ID/admission-evidence/v1", + value.admission_evidence().to_canonical_bytes().unwrap(), + evidence_id.as_digest(), + ), + domain_derivation( + "event_id", + "KRIKOS-ID/account-event/v1", + postcard::to_stdvec(&(value.body(), evidence_id)).unwrap(), + value.event_id().unwrap().as_digest(), + ), + domain_derivation( + "event_authorization_id", + "KRIKOS-ID/event-authorization/v1", + bytes.to_vec(), + value.event_authorization_id().unwrap().as_digest(), + ), + ]); + derivations + } + "SignedCheckpoint" => { + let value = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "checkpoint_id", + "KRIKOS-ID/account-checkpoint/v1", + value.body().to_canonical_bytes().unwrap(), + value.checkpoint_id().unwrap().as_digest(), + )] + } + "BeginCryptoMigration" => { + let value = BeginCryptoMigration::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "crypto_migration_id", + "KRIKOS-ID/crypto-migration/v1", + value.migration().to_canonical_bytes().unwrap(), + value.migration().crypto_migration_id().unwrap().as_digest(), + )] + } + "EventIntentApprovalBody" => { + let value = EventIntentApprovalBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "event_intent_approval_id", + "KRIKOS-ID/event-intent-approval/v1", + bytes.to_vec(), + value.event_intent_approval_id().unwrap().as_digest(), + )] + } + "ControllerApprovalBody" => { + let value = ControllerApprovalBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "controller_approval_id", + "KRIKOS-ID/controller-approval/v1", + bytes.to_vec(), + value.controller_approval_id().unwrap().as_digest(), + )] + } + "RecoveryAuthorityPlan" => { + let value = RecoveryAuthorityPlan::from_canonical_bytes(bytes).unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, value, Extensions::default()) + .unwrap(); + vec![domain_derivation( + "recovery_id", + "KRIKOS-ID/recovery/v1", + proposal.to_canonical_bytes().unwrap(), + proposal.recovery_id().unwrap().as_digest(), + )] + } + "RecoveryProposal" => { + let value = RecoveryProposal::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "recovery_id", + "KRIKOS-ID/recovery/v1", + bytes.to_vec(), + value.recovery_id().unwrap().as_digest(), + )] + } + "BeginRecovery" => { + let value = BeginRecovery::from_canonical_bytes(bytes).unwrap(); + derivations_for_wire_type( + "RecoveryProposal", + &value.proposal().to_canonical_bytes().unwrap(), + ) + } + "ForkDescriptor" => { + let value = ForkDescriptor::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "fork_id", + "KRIKOS-ID/fork/v1", + postcard::to_stdvec(&(value.common_ancestor(), value.heads())).unwrap(), + value.fork_id().unwrap().as_digest(), + )] + } + "CapabilityGrant" => { + let value = CapabilityGrant::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "capability_grant_id", + "KRIKOS-ID/capability-grant/v1", + bytes.to_vec(), + value.capability_grant_id().unwrap().as_digest(), + )] + } + "DelegationBody" => { + let value = DelegationBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "delegation_id", + "KRIKOS-ID/capability-delegation/v1", + bytes.to_vec(), + value.delegation_id().unwrap().as_digest(), + )] + } + "SignedApplicationEvent" => { + let value = SignedApplicationEvent::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "application_event_id", + "KRIKOS-ID/application-event/v1", + bytes.to_vec(), + value.application_event_id().unwrap().as_digest(), + )] + } + "WrappedGroupKey" => { + let value = WrappedGroupKey::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "group_key_wrap_id", + "KRIKOS-ID/group-key-wrap/v1", + bytes.to_vec(), + value.group_key_wrap_id().unwrap().as_digest(), + )] + } + "ProviderLogEntryBody" => { + let value = ProviderLogEntryBody::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "merkle_leaf_hash", + "KRIKOS-ID/provider-log-entry/v1", + bytes.to_vec(), + &value.merkle_leaf_hash().unwrap(), + )] + } + "MerkleSetLeaf" => { + let value = MerkleSetLeaf::from_canonical_bytes(bytes).unwrap(); + vec![merkle_leaf_derivation("merkle_leaf_hash", &value)] + } + "MerkleNonMembershipProof" => { + let value = MerkleNonMembershipProof::from_canonical_bytes(bytes).unwrap(); + merkle_non_membership_derivations(&value) + } + "PairingTicket" => { + let value = PairingTicket::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "pairing_ticket_id", + "KRIKOS-ID/pairing-ticket-id/v1", + bytes.to_vec(), + value.ticket_id().unwrap().as_digest(), + )] + } + "PairingTranscript" => { + let value = PairingTranscript::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "pairing_transcript_id", + "KRIKOS-ID/pairing-transcript-id/v1", + bytes.to_vec(), + value.transcript_id().unwrap().as_digest(), + )] + } + "PairingPossessionProof" => { + let value = PairingPossessionProof::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "pairing_proof_id", + "KRIKOS-ID/pairing-possession-proof-id/v1", + bytes.to_vec(), + value.proof_id().unwrap().as_digest(), + )] + } + "DeviceAuthorizationProposal" => { + let value = DeviceAuthorizationProposal::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "device_authorization_proposal_id", + "KRIKOS-ID/device-authorization-proposal-id/v1", + bytes.to_vec(), + value.proposal_id().unwrap().as_digest(), + )] + } + "PresenceProof" => { + let value = PresenceProof::from_canonical_bytes(bytes).unwrap(); + vec![derive_key_derivation( + "presence_proof_id", + "KRIKOS-ID/device-presence-proof-id/v1", + bytes.to_vec(), + value.proof_id().unwrap().as_digest(), + )] + } + "BackupAuthorityBundle" => { + let value = BackupAuthorityBundle::from_canonical_bytes(bytes).unwrap(); + let mut derivations = derivations_for_wire_type( + "AccountGenesis", + &value.genesis().to_canonical_bytes().unwrap(), + ); + for event in value.events() { + derivations.extend(derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + )); + } + derivations.extend(derivations_for_wire_type( + "SignedCheckpoint", + &value.checkpoint().to_canonical_bytes().unwrap(), + )); + derivations + } + "ProviderGenerationExportChunk" => { + let value = ProviderGenerationExportChunk::from_canonical_bytes(bytes).unwrap(); + let chunk_commitment = value.commitment().unwrap(); + let mut derivations = vec![ + domain_derivation( + "provider_generation_chunk_commitment", + "KRIKOS-ID/provider-generation-chunk/v1", + bytes.to_vec(), + &chunk_commitment, + ), + provider_chunk_list_derivation( + "provider_generation_chunk_list_commitment", + "KRIKOS-ID/provider-generation-chunk-list/v1", + value.component().unwrap().code(), + &[chunk_commitment], + ), + ]; + let mirror: GenerationChunkMirror = postcard::from_bytes(bytes).unwrap(); + if value.component() == Ok(ProviderExportComponent::CheckpointBundles) { + for item in chunk_items(&mirror.payload) { + let item: ProviderCheckpointBundleItemMirror = + postcard::from_bytes(&item).unwrap(); + if let Some(genesis) = &item.bundle.genesis { + derivations.extend(derivations_for_wire_type( + "AccountGenesis", + &genesis.to_canonical_bytes().unwrap(), + )); + } + for event in &item.bundle.events { + derivations.extend(derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + )); + } + derivations.extend(derivations_for_wire_type( + "SignedCheckpoint", + &item.bundle.checkpoint.to_canonical_bytes().unwrap(), + )); + if let Some(event) = &item.bundle.transition_event { + derivations.extend(derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + )); + } + } + } + derivations + } + "ProviderAuditExportChunk" => { + let value = ProviderAuditExportChunk::from_canonical_bytes(bytes).unwrap(); + let chunk_commitment = value.commitment().unwrap(); + vec![ + domain_derivation( + "provider_audit_chunk_commitment", + "KRIKOS-ID/provider-audit-chunk/v1", + bytes.to_vec(), + &chunk_commitment, + ), + provider_chunk_list_derivation( + "provider_audit_chunk_list_commitment", + "KRIKOS-ID/provider-audit-chunk-list/v1", + 0, + &[chunk_commitment], + ), + ] + } + "ProviderGenerationExportManifest" => { + let value = ProviderGenerationExportManifest::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "provider_generation_manifest_commitment", + "KRIKOS-ID/provider-generation-manifest/v1", + bytes.to_vec(), + &value.commitment().unwrap(), + )] + } + "ProviderAuditExportManifest" => { + let value = ProviderAuditExportManifest::from_canonical_bytes(bytes).unwrap(); + vec![domain_derivation( + "provider_audit_manifest_commitment", + "KRIKOS-ID/provider-audit-manifest/v1", + bytes.to_vec(), + &value.commitment().unwrap(), + )] + } + "ProviderRecoveryExportManifest" => { + let value = ProviderRecoveryExportManifest::from_canonical_bytes(bytes).unwrap(); + let mut derivations = vec![domain_derivation( + "provider_recovery_manifest_commitment", + "KRIKOS-ID/provider-recovery-manifest/v1", + bytes.to_vec(), + &value.commitment().unwrap(), + )]; + derivations.extend(derivations_for_wire_type( + "ProviderGenerationExportManifest", + &value.generation().to_canonical_bytes().unwrap(), + )); + derivations.extend(derivations_for_wire_type( + "ProviderAuditExportManifest", + &value.audit().to_canonical_bytes().unwrap(), + )); + derivations + } + "SyncFrame" => { + let value = SyncFrame::from_canonical_bytes(bytes).unwrap(); + value + .events() + .iter() + .flat_map(|event| { + derivations_for_wire_type( + "AuthorizedEvent", + &event.to_canonical_bytes().unwrap(), + ) + }) + .collect() + } + "SyncResponse" => SyncResponse::from_canonical_bytes(bytes) + .unwrap() + .as_frame() + .map_or_else(Vec::new, |frame| { + derivations_for_wire_type("SyncFrame", &frame.to_canonical_bytes().unwrap()) + }), + "AuthorizedProposalRequest" => { + let value = AuthorizedProposalRequest::from_canonical_bytes(bytes).unwrap(); + derivations_for_wire_type( + "DeviceAuthorizationProposal", + &value.proposal().to_canonical_bytes().unwrap(), + ) + } + "AuthorizedCheckpointRequest" => { + let value = AuthorizedCheckpointRequest::from_canonical_bytes(bytes).unwrap(); + derivations_for_wire_type( + "SignedCheckpoint", + &value.checkpoint().to_canonical_bytes().unwrap(), + ) + } + "IdentityProtocolReply" => IdentityProtocolReply::from_canonical_bytes(bytes) + .unwrap() + .as_sync() + .map_or_else(Vec::new, |response| { + derivations_for_wire_type("SyncResponse", &response.to_canonical_bytes().unwrap()) + }), + _ => Vec::new(), + } +} + +fn expected_derivations( + vector: &VectorMetadata, + bytes: &[u8], + directory: &Path, + vectors: &BTreeMap<&str, &VectorMetadata>, +) -> Vec { + let mut expected = derivations_for_wire_type(&vector.wire_type, bytes); + match vector.wire_type.as_str() { + "MerkleInclusionProof" => { + assert_eq!(vector.dependencies, ["merkle-set-leaf"]); + let dependency = vectors["merkle-set-leaf"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let leaf = MerkleSetLeaf::from_canonical_bytes(&dependency_bytes).unwrap(); + let proof = MerkleInclusionProof::from_canonical_bytes(bytes).unwrap(); + expected.extend(merkle_inclusion_derivations( + &leaf, + &proof, + "merkle_leaf_hash", + "merkle_root", + )); + } + "MerkleConsistencyProof" => { + assert_eq!(vector.dependencies, ["merkle-set-leaf"]); + let dependency = vectors["merkle-set-leaf"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let leaf = MerkleSetLeaf::from_canonical_bytes(&dependency_bytes).unwrap(); + let proof = MerkleConsistencyProof::from_canonical_bytes(bytes).unwrap(); + expected.extend(merkle_consistency_derivations(&leaf, &proof)); + } + "IdentityProtocolAck" => { + assert_eq!(vector.dependencies, ["sync-request"]); + let request_vector = vectors["sync-request"]; + assert_eq!(request_vector.wire_type, "SyncRequest"); + let request_bytes = fs::read(directory.join(&request_vector.canonical_file)).unwrap(); + let ack = IdentityProtocolAck::from_canonical_bytes(bytes).unwrap(); + assert_eq!(ack.protocol(), Ok(IdentityProtocolKind::Sync)); + expected.push(network_request_commitment_derivation(&ack, &request_bytes)); + } + "IdentityProtocolReply" => { + let reply = IdentityProtocolReply::from_canonical_bytes(bytes).unwrap(); + if let Some(ack) = reply.as_ack() { + assert_eq!(vector.dependencies, ["identity-protocol-ack"]); + let ack_vector = vectors["identity-protocol-ack"]; + let ack_bytes = fs::read(directory.join(&ack_vector.canonical_file)).unwrap(); + let dependency_ack = IdentityProtocolAck::from_canonical_bytes(&ack_bytes).unwrap(); + assert_eq!(ack, &dependency_ack); + expected.extend(expected_derivations( + ack_vector, &ack_bytes, directory, vectors, + )); + } + } + "OpaqueProviderAnchorCommitment" => { + assert_eq!(vector.dependencies, ["provider-compaction-manifest"]); + let manifest_vector = vectors["provider-compaction-manifest"]; + let manifest_bytes = fs::read(directory.join(&manifest_vector.canonical_file)).unwrap(); + let manifest = + ProviderCompactionManifest::from_canonical_bytes(&manifest_bytes).unwrap(); + let anchor = OpaqueProviderAnchorCommitment::from_canonical_bytes(bytes).unwrap(); + assert_eq!( + OpaqueProviderAnchorCommitment::from_compaction_manifest(&manifest).unwrap(), + anchor + ); + expected.push(provider_anchor_commitment_derivation(anchor, &manifest)); + } + _ => {} + } + expected +} + +fn validate_derivations( + vector: &VectorMetadata, + bytes: &[u8], + directory: &Path, + vectors: &BTreeMap<&str, &VectorMetadata>, +) { + let expected = expected_derivations(vector, bytes, directory, vectors); + assert_eq!( + vector.derivations, expected, + "{} derivation metadata must come from its decoded canonical object", + vector.name + ); + for derivation in &vector.derivations { + let message = hex::decode(&derivation.message_hex).unwrap(); + let output = match derivation.algorithm.as_str() { + "BLAKE3-256(domain || 0x00 || message)" => { + let mut hasher = blake3::Hasher::new(); + hasher.update(derivation.domain_or_context_ascii.as_bytes()); + hasher.update(&[0]); + hasher.update(&message); + *hasher.finalize().as_bytes() + } + "BLAKE3 derive_key(context, message)" => { + blake3::derive_key(&derivation.domain_or_context_ascii, &message) + } + other => panic!( + "{} has unsupported derivation algorithm {other}", + vector.name + ), + }; + assert_eq!( + hex::encode(output), + derivation.expected_output_hex, + "{} derivation {} does not reproduce from its declared message", + vector.name, + derivation.output_name + ); + assert_eq!( + vector.expected_ids.get(&derivation.output_name), + Some(&format!("b3:{}", derivation.expected_output_hex)), + "{} must cover derivation output {} in expected_ids", + vector.name, + derivation.output_name + ); + } +} + +fn validate_expected_ids(vector: &VectorMetadata, bytes: &[u8]) { + if matches!( + vector.wire_type.as_str(), + "MerkleInclusionProof" | "MerkleConsistencyProof" | "MerkleNonMembershipProof" + ) { + return; + } + let actual = match vector.wire_type.as_str() { + "AccountGenesis" => { + let value = AccountGenesis::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([ + ("account_id", value.account_id().unwrap().to_string()), + ( + "genesis_anchor", + value.genesis_anchor().unwrap().to_string(), + ), + ]) + } + "EventBody" => { + let value = EventBody::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([("proposal_id", value.proposal_id().unwrap().to_string())]) + } + "AdmissionEvidence" => { + let value = AdmissionEvidence::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "admission_evidence_id", + value.admission_evidence_id().unwrap().to_string(), + )]) + } + "AuthorizedEvent" => { + let value = AuthorizedEvent::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([ + ( + "proposal_id", + value.body().proposal_id().unwrap().to_string(), + ), + ( + "admission_evidence_id", + value + .admission_evidence() + .admission_evidence_id() + .unwrap() + .to_string(), + ), + ("event_id", value.event_id().unwrap().to_string()), + ( + "event_authorization_id", + value.event_authorization_id().unwrap().to_string(), + ), + ]) + } + "SignedCheckpoint" => { + let value = SignedCheckpoint::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([("checkpoint_id", value.checkpoint_id().unwrap().to_string())]) + } + "BeginCryptoMigration" => { + let value = BeginCryptoMigration::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "crypto_migration_id", + value.migration().crypto_migration_id().unwrap().to_string(), + )]) + } + "EventIntentApprovalBody" => { + let value = EventIntentApprovalBody::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "event_intent_approval_id", + value.event_intent_approval_id().unwrap().to_string(), + )]) + } + "ControllerApprovalBody" => { + let value = ControllerApprovalBody::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "controller_approval_id", + value.controller_approval_id().unwrap().to_string(), + )]) + } + "RecoveryAuthorityPlan" => { + let value = RecoveryAuthorityPlan::from_canonical_bytes(bytes).unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, value, Extensions::default()) + .unwrap(); + BTreeMap::from([("recovery_id", proposal.recovery_id().unwrap().to_string())]) + } + "ForkDescriptor" => { + let value = ForkDescriptor::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([("fork_id", value.fork_id().unwrap().to_string())]) + } + "CapabilityGrant" => { + let value = CapabilityGrant::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "capability_grant_id", + value.capability_grant_id().unwrap().to_string(), + )]) + } + "DelegationBody" => { + let value = DelegationBody::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([("delegation_id", value.delegation_id().unwrap().to_string())]) + } + "SignedApplicationEvent" => { + let value = SignedApplicationEvent::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "application_event_id", + value.application_event_id().unwrap().to_string(), + )]) + } + "WrappedGroupKey" => { + let value = WrappedGroupKey::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "group_key_wrap_id", + value.group_key_wrap_id().unwrap().to_string(), + )]) + } + "ProviderLogEntryBody" => { + let value = ProviderLogEntryBody::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "merkle_leaf_hash", + value.merkle_leaf_hash().unwrap().to_string(), + )]) + } + "PairingTicket" => { + let value = PairingTicket::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "pairing_ticket_id", + value.ticket_id().unwrap().as_digest().to_string(), + )]) + } + "PairingTranscript" => { + let value = PairingTranscript::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "pairing_transcript_id", + value.transcript_id().unwrap().as_digest().to_string(), + )]) + } + "PairingPossessionProof" => { + let value = PairingPossessionProof::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "pairing_proof_id", + value.proof_id().unwrap().as_digest().to_string(), + )]) + } + "DeviceAuthorizationProposal" => { + let value = DeviceAuthorizationProposal::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "device_authorization_proposal_id", + value.proposal_id().unwrap().as_digest().to_string(), + )]) + } + "PresenceProof" => { + let value = PresenceProof::from_canonical_bytes(bytes).unwrap(); + BTreeMap::from([( + "presence_proof_id", + value.proof_id().unwrap().as_digest().to_string(), + )]) + } + _ => BTreeMap::new(), + }; + let derived = vector + .derivations + .iter() + .map(|derivation| { + ( + derivation.output_name.as_str(), + format!("b3:{}", derivation.expected_output_hex), + ) + }) + .collect::>(); + let actual_names = actual + .keys() + .copied() + .chain(derived.keys().copied()) + .collect::>(); + assert_eq!( + actual_names, + vector + .expected_ids + .keys() + .map(String::as_str) + .collect::>(), + "{} expected_ids must be an exact decoded/derived inventory", + vector.name + ); + for (name, expected) in &vector.expected_ids { + let recomputed = actual + .get(name.as_str()) + .or_else(|| derived.get(name.as_str())); + assert_eq!( + recomputed, + Some(expected), + "{} expected ID {name} was not recomputed from its binary", + vector.name + ); + } +} + +fn validate_version_metadata(vector: &VectorMetadata) { + let is_digest_id = matches!( + vector.wire_type.as_str(), + "GenesisAnchor" + | "AccountId" + | "ControllerId" + | "ControllerKeyId" + | "ControlPolicyId" + | "RecoveryPolicyId" + | "ProviderId" + | "ProviderLogId" + | "ProviderPolicyId" + | "DeviceId" + | "CapabilityGrantId" + | "DelegationId" + | "ProposalId" + | "EventId" + | "EventAuthorizationId" + | "AdmissionEvidenceId" + | "ControllerApprovalId" + | "EventIntentApprovalId" + | "CheckpointId" + | "RecoveryId" + | "GuardianGrantId" + | "ForkId" + | "CryptoSuiteId" + | "CryptoMigrationId" + | "CryptoStateId" + | "ApplicationId" + | "ApplicationEventId" + | "GroupId" + | "GroupKeyWrapId" + ); + let is_merkle = matches!( + vector.wire_type.as_str(), + "MerkleSetKey" + | "MerkleSetLeaf" + | "MerkleInclusionProof" + | "MerkleConsistencyProof" + | "MerkleNonMembershipProof" + ); + let is_provider_interchange = matches!( + vector.wire_type.as_str(), + "ProviderExportComponent" + | "ProviderExportComponentDescriptor" + | "ProviderGenerationExportChunk" + | "ProviderAuditExportChunk" + | "ProviderGenerationExportManifest" + | "ProviderAuditExportManifest" + | "ProviderRecoveryExportManifest" + ); + if is_digest_id { + assert_eq!(vector.protocol_version, None); + assert!( + vector + .version_scope + .contains("standalone-algorithm-tagged-digest") + ); + } else if is_merkle { + assert_eq!(vector.protocol_version, None); + assert!(vector.version_scope.contains("standalone Merkle structure")); + } else if is_provider_interchange { + assert_eq!(vector.protocol_version, Some(1)); + assert_eq!( + vector.version_scope, + "authoritative-provider-interchange-format-v1" + ); + } else { + let exact_scope = match vector.wire_type.as_str() { + "EndpointAuthorizationRequest" + | "SyncCursor" + | "SyncRequest" + | "SyncFrame" + | "SyncResponse" + | "IdentityProtocolAck" + | "IdentityProtocolReply" => Some("authoritative-top-level-v1"), + "AuthorizedSyncRequest" => { + Some("v1 inherited from exact nested authorization and sync request") + } + "AuthorizedProposalRequest" => { + Some("v1 inherited from exact nested authorization and proposal") + } + "AuthorizedCheckpointRequest" => { + Some("v1 inherited from exact nested authorization and checkpoint") + } + "ControllerKeyBindingProof" => Some("v1 inherited from exact crypto migration begin"), + "ProviderCompactionManifest" => Some("authoritative-provider-compaction-format-v1"), + "OpaqueProviderAnchorCommitment" => Some("authoritative-provider-anchor-format-v1"), + _ => None, + }; + if let Some(exact_scope) = exact_scope { + assert_eq!(vector.protocol_version, Some(1)); + assert_eq!(vector.version_scope, exact_scope); + } else { + assert!(!vector.version_scope.is_empty()); + } + } +} + +fn validate_cross_vector_dependencies( + vector: &VectorMetadata, + bytes: &[u8], + directory: &Path, + vectors: &BTreeMap<&str, &VectorMetadata>, +) { + match vector.wire_type.as_str() { + "SignedEventIntentApproval" => { + assert_eq!(vector.dependencies, ["event-intent-approval-body"]); + let dependency = vectors["event-intent-approval-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = EventIntentApprovalBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let approval = SignedEventIntentApproval::from_canonical_bytes(bytes).unwrap(); + assert_eq!(approval.body(), &body); + } + "EventIntentApprovals" => { + assert_eq!(vector.dependencies, ["event-intent-approval"]); + let dependency = vectors["event-intent-approval"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let approval = + SignedEventIntentApproval::from_canonical_bytes(&dependency_bytes).unwrap(); + let approvals = EventIntentApprovals::from_canonical_bytes(bytes).unwrap(); + assert_eq!(approvals.as_slice(), std::slice::from_ref(&approval)); + } + "AdmissionEvidence" => { + assert_eq!(vector.dependencies, ["event-body"]); + let dependency = vectors["event-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = EventBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let evidence = AdmissionEvidence::from_canonical_bytes(bytes).unwrap(); + assert_eq!(evidence.proposal_id(), body.proposal_id().unwrap()); + } + "ControllerApprovalBody" => { + assert_eq!(vector.dependencies, ["event-body", "admission-evidence"]); + let body = EventBody::from_canonical_bytes( + &fs::read(directory.join(&vectors["event-body"].canonical_file)).unwrap(), + ) + .unwrap(); + let evidence = AdmissionEvidence::from_canonical_bytes( + &fs::read(directory.join(&vectors["admission-evidence"].canonical_file)).unwrap(), + ) + .unwrap(); + let approval_body = ControllerApprovalBody::from_canonical_bytes(bytes).unwrap(); + assert_eq!( + approval_body.event_subject(), + Some(( + evidence.event_id_for_body(&body).unwrap(), + evidence.admission_evidence_id().unwrap(), + )) + ); + } + "SignedControllerApproval" => { + assert_eq!( + vector.dependencies, + ["final-event-controller-approval-body"] + ); + let dependency = vectors["final-event-controller-approval-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = ControllerApprovalBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let approval = SignedControllerApproval::from_canonical_bytes(bytes).unwrap(); + assert_eq!(approval.body(), &body); + } + "ControllerApprovals" => { + assert_eq!(vector.dependencies, ["final-event-controller-approval"]); + let dependency = vectors["final-event-controller-approval"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let approval = + SignedControllerApproval::from_canonical_bytes(&dependency_bytes).unwrap(); + let approvals = ControllerApprovals::from_canonical_bytes(bytes).unwrap(); + assert_eq!(approvals.as_slice(), std::slice::from_ref(&approval)); + } + "AuthorizedEvent" => { + assert_eq!( + vector.dependencies, + [ + "event-body", + "admission-evidence", + "final-event-controller-approval" + ] + ); + let body = EventBody::from_canonical_bytes( + &fs::read(directory.join(&vectors["event-body"].canonical_file)).unwrap(), + ) + .unwrap(); + let evidence = AdmissionEvidence::from_canonical_bytes( + &fs::read(directory.join(&vectors["admission-evidence"].canonical_file)).unwrap(), + ) + .unwrap(); + let approval = SignedControllerApproval::from_canonical_bytes( + &fs::read( + directory.join(&vectors["final-event-controller-approval"].canonical_file), + ) + .unwrap(), + ) + .unwrap(); + let event = AuthorizedEvent::from_canonical_bytes(bytes).unwrap(); + assert_eq!(event.body(), &body); + assert_eq!(event.admission_evidence(), &evidence); + assert_eq!( + event.approvals().as_slice(), + std::slice::from_ref(&approval) + ); + } + "SignedGuardianApproval" => { + assert_eq!(vector.dependencies, ["guardian-approval-body"]); + let dependency = vectors["guardian-approval-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = GuardianApprovalBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let approval = SignedGuardianApproval::from_canonical_bytes(bytes).unwrap(); + assert_eq!(approval.body(), &body); + } + "GuardianApprovalSet" => { + assert_eq!(vector.dependencies, ["signed-guardian-approval"]); + let dependency = vectors["signed-guardian-approval"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let approval = SignedGuardianApproval::from_canonical_bytes(&dependency_bytes).unwrap(); + let approvals = GuardianApprovalSet::from_canonical_bytes(bytes).unwrap(); + assert!( + approvals.as_slice().contains(&approval), + "guardian approval set must contain the exact declared signed approval" + ); + } + "RecoveryThresholdEvidence" => { + assert_eq!(vector.dependencies, ["guardian-approval-set"]); + let dependency = vectors["guardian-approval-set"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let approvals = GuardianApprovalSet::from_canonical_bytes(&dependency_bytes).unwrap(); + let evidence = RecoveryThresholdEvidence::from_canonical_bytes(bytes).unwrap(); + assert_eq!(evidence.as_guardian_approvals(), Some(&approvals)); + } + "BackupAuthorityBundle" => { + assert_eq!( + vector.dependencies, + ["account-genesis", "authorized-event", "checkpoint-direct"] + ); + let genesis = AccountGenesis::from_canonical_bytes( + &fs::read(directory.join(&vectors["account-genesis"].canonical_file)).unwrap(), + ) + .unwrap(); + let event = AuthorizedEvent::from_canonical_bytes( + &fs::read(directory.join(&vectors["authorized-event"].canonical_file)).unwrap(), + ) + .unwrap(); + let checkpoint = SignedCheckpoint::from_canonical_bytes( + &fs::read(directory.join(&vectors["checkpoint-direct"].canonical_file)).unwrap(), + ) + .unwrap(); + let bundle = BackupAuthorityBundle::from_canonical_bytes(bytes).unwrap(); + assert_eq!(bundle.genesis(), &genesis); + assert_eq!(bundle.events(), std::slice::from_ref(&event)); + assert_eq!(bundle.checkpoint(), &checkpoint); + } + "BackupEnvelope" => { + assert_eq!(vector.dependencies, ["backup-authority-bundle"]); + let dependency = vectors["backup-authority-bundle"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let bundle = BackupAuthorityBundle::from_canonical_bytes(&dependency_bytes).unwrap(); + let decrypted_bundle = backup_envelope_authority_bundle(bytes); + assert_eq!(decrypted_bundle, bundle); + } + "CapabilityRoot" => { + assert_eq!(vector.dependencies, ["capability-root-grant"]); + let dependency = vectors["capability-root-grant"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let grant = CapabilityGrant::from_canonical_bytes(&dependency_bytes).unwrap(); + let root = CapabilityRoot::from_canonical_bytes(bytes).unwrap(); + assert_eq!(root.grant(), &grant); + } + "DelegationBody" => { + assert_eq!(vector.dependencies, ["capability-grant"]); + let dependency = vectors["capability-grant"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let grant = CapabilityGrant::from_canonical_bytes(&dependency_bytes).unwrap(); + let body = DelegationBody::from_canonical_bytes(bytes).unwrap(); + assert_eq!(body.child_grant(), &grant); + } + "SignedDelegation" => { + assert_eq!(vector.dependencies, ["delegation-body"]); + let dependency = vectors["delegation-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = DelegationBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let delegation = SignedDelegation::from_canonical_bytes(bytes).unwrap(); + assert_eq!(delegation.body(), &body); + } + "DelegationChain" => { + assert_eq!( + vector.dependencies, + ["capability-root", "signed-delegation"] + ); + let root = CapabilityRoot::from_canonical_bytes( + &fs::read(directory.join(&vectors["capability-root"].canonical_file)).unwrap(), + ) + .unwrap(); + let delegation = SignedDelegation::from_canonical_bytes( + &fs::read(directory.join(&vectors["signed-delegation"].canonical_file)).unwrap(), + ) + .unwrap(); + let chain = DelegationChain::from_canonical_bytes(bytes).unwrap(); + assert_eq!(chain.root(), &root); + assert_eq!(chain.links(), std::slice::from_ref(&delegation)); + } + "SignedApplicationEvent" => { + assert_eq!(vector.dependencies, ["application-event-body"]); + let dependency = vectors["application-event-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = ApplicationEventBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let event = SignedApplicationEvent::from_canonical_bytes(bytes).unwrap(); + assert_eq!(event.body(), &body); + } + "SignedSocialAttestation" => { + assert_eq!(vector.dependencies, ["social-attestation-body"]); + let dependency = vectors["social-attestation-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = SocialAttestationBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let attestation = SignedSocialAttestation::from_canonical_bytes(bytes).unwrap(); + assert_eq!(attestation.body(), &body); + } + "SignedNameClaim" => { + assert_eq!(vector.dependencies, ["name-claim-body"]); + let dependency = vectors["name-claim-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = NameClaimBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let claim = SignedNameClaim::from_canonical_bytes(bytes).unwrap(); + assert_eq!(claim.body(), &body); + } + "SignedPortableCredential" => { + assert_eq!(vector.dependencies, ["portable-credential-body"]); + let dependency = vectors["portable-credential-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = PortableCredentialBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let credential = SignedPortableCredential::from_canonical_bytes(bytes).unwrap(); + assert_eq!(credential.body(), &body); + } + "PrivateMetadataEnvelope" => { + assert_eq!(vector.dependencies, ["private-artifact-context"]); + let dependency = vectors["private-artifact-context"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let context = PrivateArtifactContext::from_canonical_bytes(&dependency_bytes).unwrap(); + let envelope = PrivateMetadataEnvelope::from_canonical_bytes(bytes).unwrap(); + assert_eq!(envelope.context(), &context); + } + "WrappedGroupKey" => { + assert_eq!(vector.dependencies, ["group-key-wrap-header"]); + let dependency = vectors["group-key-wrap-header"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let header = GroupKeyWrapHeader::from_canonical_bytes(&dependency_bytes).unwrap(); + let wrapped = WrappedGroupKey::from_canonical_bytes(bytes).unwrap(); + assert_eq!(wrapped.header(), &header); + } + "RecipientKeyWraps" => { + assert_eq!(vector.dependencies, ["wrapped-group-key"]); + let dependency = vectors["wrapped-group-key"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let wrapped = WrappedGroupKey::from_canonical_bytes(&dependency_bytes).unwrap(); + let wraps = RecipientKeyWraps::from_canonical_bytes(bytes).unwrap(); + assert_eq!(wraps.as_slice(), std::slice::from_ref(&wrapped)); + } + "AccountOperation" => { + let operation = AccountOperation::from_canonical_bytes(bytes).unwrap(); + match (&*vector.name, &operation) { + ("account-operation-13", AccountOperation::BeginRecovery(begin)) => { + assert_eq!(vector.dependencies, ["recovery-begin"]); + let dependency = vectors["recovery-begin"]; + let dependency_bytes = + fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_begin = + BeginRecovery::from_canonical_bytes(&dependency_bytes).unwrap(); + assert_eq!(begin, &dependency_begin); + } + ("account-operation-14", AccountOperation::VetoRecovery(veto)) => { + assert_eq!(vector.dependencies, ["recovery-veto"]); + let dependency = vectors["recovery-veto"]; + let dependency_bytes = + fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_veto = + VetoRecovery::from_canonical_bytes(&dependency_bytes).unwrap(); + assert_eq!(veto, &dependency_veto); + } + ("account-operation-15", AccountOperation::CancelRecovery(cancel)) => { + assert_eq!(vector.dependencies, ["recovery-cancel"]); + let dependency = vectors["recovery-cancel"]; + let dependency_bytes = + fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_cancel = + CancelRecovery::from_canonical_bytes(&dependency_bytes).unwrap(); + assert_eq!(cancel, &dependency_cancel); + } + ("account-operation-16", AccountOperation::FinalizeRecovery(finalize)) => { + assert_eq!(vector.dependencies, ["recovery-finalize"]); + let dependency = vectors["recovery-finalize"]; + let dependency_bytes = + fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_finalize = + FinalizeRecovery::from_canonical_bytes(&dependency_bytes).unwrap(); + assert_eq!(finalize, &dependency_finalize); + } + ("account-operation-17", AccountOperation::ResolveFork(resolve)) => { + assert_eq!(vector.dependencies, ["fork-descriptor"]); + let dependency = vectors["fork-descriptor"]; + let dependency_bytes = + fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_fork = + ForkDescriptor::from_canonical_bytes(&dependency_bytes).unwrap(); + assert_eq!(resolve.fork(), &dependency_fork); + } + ("account-operation-18", AccountOperation::BeginCryptoMigration(begin)) => { + assert_eq!(vector.dependencies, ["crypto-migration-begin"]); + let dependency = vectors["crypto-migration-begin"]; + let dependency_bytes = + fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_begin = + BeginCryptoMigration::from_canonical_bytes(&dependency_bytes).unwrap(); + assert_eq!(begin, &dependency_begin); + } + _ => assert!( + vector.dependencies.is_empty(), + "{} has an undeclared account-operation dependency rule", + vector.name + ), + } + } + "FinalizeRecovery" => { + assert_eq!(vector.dependencies, ["recovery-delay-anchor"]); + let dependency = vectors["recovery-delay-anchor"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_anchor = + RecoveryDelayAnchor::from_canonical_bytes(&dependency_bytes).unwrap(); + let finalize = FinalizeRecovery::from_canonical_bytes(bytes).unwrap(); + assert_eq!(finalize.delay_anchor(), &dependency_anchor); + } + "RecoveryProposal" => { + assert_eq!(vector.dependencies, ["recovery-authority-plan"]); + let dependency = vectors["recovery-authority-plan"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_plan = + RecoveryAuthorityPlan::from_canonical_bytes(&dependency_bytes).unwrap(); + let proposal = RecoveryProposal::from_canonical_bytes(bytes).unwrap(); + assert_eq!(proposal.plan(), &dependency_plan); + } + "BeginRecovery" => { + assert_eq!(vector.dependencies, ["recovery-proposal"]); + let dependency = vectors["recovery-proposal"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let dependency_proposal = + RecoveryProposal::from_canonical_bytes(&dependency_bytes).unwrap(); + let begin = BeginRecovery::from_canonical_bytes(bytes).unwrap(); + assert_eq!(begin.proposal(), &dependency_proposal); + } + "ControllerKeyBindingProof" => { + assert_eq!(vector.dependencies, ["crypto-migration-begin"]); + let dependency = vectors["crypto-migration-begin"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let begin = BeginCryptoMigration::from_canonical_bytes(&dependency_bytes).unwrap(); + let proof = ControllerKeyBindingProof::from_canonical_bytes(bytes).unwrap(); + assert_eq!(begin.proofs().as_slice(), std::slice::from_ref(&proof)); + } + "RecoveryDelayAnchor" | "BeginCryptoMigration" => { + assert!(vector.dependencies.is_empty()); + } + "MerkleInclusionProof" => { + assert_eq!(vector.dependencies, ["merkle-set-leaf"]); + let dependency = vectors["merkle-set-leaf"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let leaf = MerkleSetLeaf::from_canonical_bytes(&dependency_bytes).unwrap(); + let proof = MerkleInclusionProof::from_canonical_bytes(bytes).unwrap(); + let derivations = + merkle_inclusion_derivations(&leaf, &proof, "merkle_leaf_hash", "merkle_root"); + let root = derivation_output_digest(derivations.last().unwrap()); + assert_eq!(root.to_string(), vector.expected_ids["merkle_root"]); + proof.verify(&leaf, root).unwrap(); + } + "MerkleConsistencyProof" => { + assert_eq!(vector.dependencies, ["merkle-set-leaf"]); + let dependency = vectors["merkle-set-leaf"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let leaf = MerkleSetLeaf::from_canonical_bytes(&dependency_bytes).unwrap(); + let proof = MerkleConsistencyProof::from_canonical_bytes(bytes).unwrap(); + let derivations = merkle_consistency_derivations(&leaf, &proof); + let old_root = derivation_output_digest(derivations.first().unwrap()); + let new_root = derivation_output_digest(derivations.last().unwrap()); + assert_eq!(old_root.to_string(), vector.expected_ids["old_merkle_root"]); + assert_eq!(new_root.to_string(), vector.expected_ids["new_merkle_root"]); + proof.verify(old_root, new_root).unwrap(); + } + "MerkleNonMembershipProof" => { + assert_eq!(vector.dependencies, ["merkle-set-key"]); + let dependency = vectors["merkle-set-key"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let missing_key = MerkleSetKey::from_canonical_bytes(&dependency_bytes).unwrap(); + assert_eq!( + hex::encode(missing_key.to_canonical_bytes().unwrap()), + vector.expected_ids["missing_key"] + ); + let proof = MerkleNonMembershipProof::from_canonical_bytes(bytes).unwrap(); + let derivations = merkle_non_membership_derivations(&proof); + let root = derivation_output_digest(derivations.last().unwrap()); + assert_eq!(root.to_string(), vector.expected_ids["merkle_root"]); + proof.verify(missing_key, root).unwrap(); + if let Some(predecessor) = proof.predecessor() { + assert!(predecessor.leaf().key() < missing_key); + } + if let Some(successor) = proof.successor() { + assert!(missing_key < successor.leaf().key()); + } + } + "IdentityProtocolAck" => { + assert_eq!(vector.dependencies, ["sync-request"]); + let dependency = vectors["sync-request"]; + let request_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let ack = IdentityProtocolAck::from_canonical_bytes(bytes).unwrap(); + let expected = IdentityProtocolAck::for_canonical_request( + IdentityProtocolKind::Sync, + &request_bytes, + IdentityServiceOutcome::Accepted, + ); + assert_eq!(ack, expected); + } + "ProviderHeadBody" => { + assert_eq!(vector.dependencies, ["provider-log-entry"]); + let dependency = vectors["provider-log-entry"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let entry = ProviderLogEntryBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let head = ProviderHeadBody::from_canonical_bytes(bytes).unwrap(); + assert_eq!(head.provider_id(), entry.provider_id()); + assert_eq!(head.log_id(), entry.log_id()); + assert_eq!(head.tree_size(), 1); + assert_eq!(head.tree_root(), entry.merkle_leaf_hash().unwrap()); + assert!(head.observed_at() >= entry.observed_at()); + } + "SignedProviderHead" => { + assert_eq!(vector.dependencies, ["provider-head-body"]); + let dependency = vectors["provider-head-body"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let body = ProviderHeadBody::from_canonical_bytes(&dependency_bytes).unwrap(); + let head = SignedProviderHead::from_canonical_bytes(bytes).unwrap(); + assert_eq!(head.body(), &body); + } + "InclusionReceipt" => { + assert_eq!( + vector.dependencies, + ["provider-log-entry", "signed-provider-head"] + ); + let entry = ProviderLogEntryBody::from_canonical_bytes( + &fs::read(directory.join(&vectors["provider-log-entry"].canonical_file)).unwrap(), + ) + .unwrap(); + let head = SignedProviderHead::from_canonical_bytes( + &fs::read(directory.join(&vectors["signed-provider-head"].canonical_file)).unwrap(), + ) + .unwrap(); + let receipt = InclusionReceipt::from_canonical_bytes(bytes).unwrap(); + assert_eq!(receipt.entry(), &entry); + assert_eq!(receipt.signed_head(), &head); + } + "ProviderReceipts" => { + assert_eq!(vector.dependencies, ["inclusion-receipt"]); + let dependency = vectors["inclusion-receipt"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let receipt = InclusionReceipt::from_canonical_bytes(&dependency_bytes).unwrap(); + let receipts = ProviderReceipts::from_canonical_bytes(bytes).unwrap(); + assert_eq!(receipts.as_slice(), std::slice::from_ref(&receipt)); + } + "ProviderEquivocationEvidence" => { + assert_eq!(vector.dependencies, ["signed-provider-head"]); + let dependency = vectors["signed-provider-head"]; + let dependency_bytes = fs::read(directory.join(&dependency.canonical_file)).unwrap(); + let head = SignedProviderHead::from_canonical_bytes(&dependency_bytes).unwrap(); + let evidence = ProviderEquivocationEvidence::from_canonical_bytes(bytes).unwrap(); + assert_eq!(evidence.first(), &head); + } + "PairingTranscript" => { + assert_eq!(vector.dependencies, ["pairing-ticket"]); + let ticket_vector = vectors["pairing-ticket"]; + let ticket_bytes = fs::read(directory.join(&ticket_vector.canonical_file)).unwrap(); + let ticket = PairingTicket::from_canonical_bytes(&ticket_bytes).unwrap(); + let transcript = PairingTranscript::from_canonical_bytes(bytes).unwrap(); + assert_eq!(transcript.ticket_id(), ticket.ticket_id().unwrap()); + assert_eq!(transcript.account_id(), ticket.account_id()); + assert_eq!(transcript.proposed_device(), ticket.proposed_device()); + } + "PairingPossessionProof" => { + assert_eq!(vector.dependencies, ["pairing-transcript"]); + let transcript_vector = vectors["pairing-transcript"]; + let transcript_bytes = + fs::read(directory.join(&transcript_vector.canonical_file)).unwrap(); + let transcript = PairingTranscript::from_canonical_bytes(&transcript_bytes).unwrap(); + let proof = PairingPossessionProof::from_canonical_bytes(bytes).unwrap(); + assert_eq!(proof.transcript_id(), transcript.transcript_id().unwrap()); + } + "PairingConfirmationContext" => { + assert_eq!(vector.dependencies, ["pairing-transcript"]); + let transcript_vector = vectors["pairing-transcript"]; + let transcript_bytes = + fs::read(directory.join(&transcript_vector.canonical_file)).unwrap(); + let transcript = PairingTranscript::from_canonical_bytes(&transcript_bytes).unwrap(); + let context = PairingConfirmationContext::from_canonical_bytes(bytes).unwrap(); + assert_eq!(context.transcript_id(), transcript.transcript_id().unwrap()); + } + "DeviceAuthorizationProposal" => { + assert_eq!( + vector.dependencies, + [ + "pairing-ticket", + "pairing-transcript", + "pairing-possession-proof", + "pairing-confirmation-context" + ] + ); + let ticket = PairingTicket::from_canonical_bytes( + &fs::read(directory.join(&vectors["pairing-ticket"].canonical_file)).unwrap(), + ) + .unwrap(); + let transcript = PairingTranscript::from_canonical_bytes( + &fs::read(directory.join(&vectors["pairing-transcript"].canonical_file)).unwrap(), + ) + .unwrap(); + let proof = PairingPossessionProof::from_canonical_bytes( + &fs::read(directory.join(&vectors["pairing-possession-proof"].canonical_file)) + .unwrap(), + ) + .unwrap(); + let confirmation = PairingConfirmationContext::from_canonical_bytes( + &fs::read(directory.join(&vectors["pairing-confirmation-context"].canonical_file)) + .unwrap(), + ) + .unwrap(); + let proposal = DeviceAuthorizationProposal::from_canonical_bytes(bytes).unwrap(); + assert_eq!(proposal.account_id(), ticket.account_id()); + assert_eq!(proposal.proposed_device(), ticket.proposed_device()); + assert_eq!(proposal.proposed_device(), transcript.proposed_device()); + assert_eq!(proposal.proposed_device_id(), ticket.proposed_device_id()); + assert_eq!(proposal.ticket_id(), ticket.ticket_id().unwrap()); + assert_eq!( + proposal.transcript_id(), + transcript.transcript_id().unwrap() + ); + assert_eq!(proposal.proof_id(), proof.proof_id().unwrap()); + assert_eq!(proposal.confirmation(), confirmation); + } + "PresenceProof" => { + assert_eq!(vector.dependencies, ["presence-challenge"]); + let challenge_vector = vectors["presence-challenge"]; + let challenge_bytes = + fs::read(directory.join(&challenge_vector.canonical_file)).unwrap(); + let challenge = + DevicePresenceChallenge::from_canonical_bytes(&challenge_bytes).unwrap(); + let proof = PresenceProof::from_canonical_bytes(bytes).unwrap(); + assert_eq!(proof.challenge(), &challenge); + } + "SyncRequest" => { + assert_eq!(vector.dependencies, ["sync-cursor"]); + let cursor_vector = vectors["sync-cursor"]; + let cursor_bytes = fs::read(directory.join(&cursor_vector.canonical_file)).unwrap(); + let cursor = SyncCursor::from_canonical_bytes(&cursor_bytes).unwrap(); + let request = SyncRequest::from_canonical_bytes(bytes).unwrap(); + assert_eq!(request.continuation(), Some(&cursor)); + } + "SyncFrame" => { + assert_eq!(vector.dependencies, ["authorized-event", "sync-cursor"]); + let event_vector = vectors["authorized-event"]; + let event_bytes = fs::read(directory.join(&event_vector.canonical_file)).unwrap(); + let event = AuthorizedEvent::from_canonical_bytes(&event_bytes).unwrap(); + let cursor_vector = vectors["sync-cursor"]; + let cursor_bytes = fs::read(directory.join(&cursor_vector.canonical_file)).unwrap(); + let cursor = SyncCursor::from_canonical_bytes(&cursor_bytes).unwrap(); + let frame = SyncFrame::from_canonical_bytes(bytes).unwrap(); + assert_eq!(frame.events(), std::slice::from_ref(&event)); + assert_eq!(frame.continuation(), Some(&cursor)); + } + "SyncResponse" => { + let response = SyncResponse::from_canonical_bytes(bytes).unwrap(); + if let Some(frame) = response.as_frame() { + assert_eq!(vector.dependencies, ["sync-frame"]); + let frame_vector = vectors["sync-frame"]; + let frame_bytes = fs::read(directory.join(&frame_vector.canonical_file)).unwrap(); + let dependency_frame = SyncFrame::from_canonical_bytes(&frame_bytes).unwrap(); + assert_eq!(frame, &dependency_frame); + } else { + assert!(response.as_complete().is_some()); + assert!(vector.dependencies.is_empty()); + } + } + "AuthorizedSyncRequest" => { + assert_eq!( + vector.dependencies, + ["endpoint-authorization-request", "sync-request"] + ); + let endpoint_vector = vectors["endpoint-authorization-request"]; + let endpoint_bytes = fs::read(directory.join(&endpoint_vector.canonical_file)).unwrap(); + let endpoint = + EndpointAuthorizationRequest::from_canonical_bytes(&endpoint_bytes).unwrap(); + let request_vector = vectors["sync-request"]; + let request_bytes = fs::read(directory.join(&request_vector.canonical_file)).unwrap(); + let request = SyncRequest::from_canonical_bytes(&request_bytes).unwrap(); + let authorized = AuthorizedSyncRequest::from_canonical_bytes(bytes).unwrap(); + assert_eq!(authorized.authorization(), endpoint); + assert_eq!(authorized.request(), &request); + } + "AuthorizedProposalRequest" => { + assert_eq!( + vector.dependencies, + [ + "proposal-endpoint-authorization-request", + "device-authorization-proposal" + ] + ); + let endpoint_vector = vectors["proposal-endpoint-authorization-request"]; + let endpoint_bytes = fs::read(directory.join(&endpoint_vector.canonical_file)).unwrap(); + let endpoint = + EndpointAuthorizationRequest::from_canonical_bytes(&endpoint_bytes).unwrap(); + let proposal_vector = vectors["device-authorization-proposal"]; + let proposal_bytes = fs::read(directory.join(&proposal_vector.canonical_file)).unwrap(); + let proposal = + DeviceAuthorizationProposal::from_canonical_bytes(&proposal_bytes).unwrap(); + let authorized = AuthorizedProposalRequest::from_canonical_bytes(bytes).unwrap(); + assert_eq!(authorized.authorization(), endpoint); + assert_eq!(authorized.proposal(), &proposal); + } + "AuthorizedCheckpointRequest" => { + assert_eq!( + vector.dependencies, + ["endpoint-authorization-request", "checkpoint-direct"] + ); + let endpoint_vector = vectors["endpoint-authorization-request"]; + let endpoint_bytes = fs::read(directory.join(&endpoint_vector.canonical_file)).unwrap(); + let endpoint = + EndpointAuthorizationRequest::from_canonical_bytes(&endpoint_bytes).unwrap(); + let checkpoint_vector = vectors["checkpoint-direct"]; + let checkpoint_bytes = + fs::read(directory.join(&checkpoint_vector.canonical_file)).unwrap(); + let checkpoint = SignedCheckpoint::from_canonical_bytes(&checkpoint_bytes).unwrap(); + let authorized = AuthorizedCheckpointRequest::from_canonical_bytes(bytes).unwrap(); + assert_eq!(authorized.authorization(), endpoint); + assert_eq!(authorized.checkpoint(), &checkpoint); + } + "IdentityProtocolReply" => { + let reply = IdentityProtocolReply::from_canonical_bytes(bytes).unwrap(); + if let Some(response) = reply.as_sync() { + assert_eq!(vector.dependencies, ["sync-response-frame"]); + let response_vector = vectors["sync-response-frame"]; + let response_bytes = + fs::read(directory.join(&response_vector.canonical_file)).unwrap(); + let dependency_response = + SyncResponse::from_canonical_bytes(&response_bytes).unwrap(); + assert_eq!(response, &dependency_response); + } else if let Some(ack) = reply.as_ack() { + assert_eq!(vector.dependencies, ["identity-protocol-ack"]); + let ack_vector = vectors["identity-protocol-ack"]; + let ack_bytes = fs::read(directory.join(&ack_vector.canonical_file)).unwrap(); + let dependency_ack = IdentityProtocolAck::from_canonical_bytes(&ack_bytes).unwrap(); + assert_eq!(ack, &dependency_ack); + } else { + panic!("identity protocol reply must carry one exact dependency") + } + } + "ProviderExportComponentDescriptor" => { + assert_eq!(vector.dependencies, ["provider-export-component"]); + let component_vector = vectors["provider-export-component"]; + let component_bytes = + fs::read(directory.join(&component_vector.canonical_file)).unwrap(); + let component = + ProviderExportComponent::from_canonical_bytes(&component_bytes).unwrap(); + let descriptor = + ProviderExportComponentDescriptor::from_canonical_bytes(bytes).unwrap(); + assert_eq!(descriptor.component().unwrap(), component); + } + "ProviderGenerationExportManifest" => { + assert_eq!( + vector.dependencies, + [ + "provider-export-component-descriptor", + "signed-provider-head" + ] + ); + let descriptor_vector = vectors["provider-export-component-descriptor"]; + let descriptor_bytes = + fs::read(directory.join(&descriptor_vector.canonical_file)).unwrap(); + let descriptor = + ProviderExportComponentDescriptor::from_canonical_bytes(&descriptor_bytes).unwrap(); + let head_vector = vectors["signed-provider-head"]; + let head_bytes = fs::read(directory.join(&head_vector.canonical_file)).unwrap(); + let head = SignedProviderHead::from_canonical_bytes(&head_bytes).unwrap(); + let manifest = ProviderGenerationExportManifest::from_canonical_bytes(bytes).unwrap(); + assert_eq!( + manifest + .descriptor(descriptor.component().unwrap()) + .unwrap(), + &descriptor + ); + assert_eq!(manifest.latest_head(), Some(&head)); + } + "ProviderAuditExportManifest" => { + assert_eq!(vector.dependencies, ["signed-provider-head"]); + let head_vector = vectors["signed-provider-head"]; + let head_bytes = fs::read(directory.join(&head_vector.canonical_file)).unwrap(); + let head = SignedProviderHead::from_canonical_bytes(&head_bytes).unwrap(); + let manifest = ProviderAuditExportManifest::from_canonical_bytes(bytes).unwrap(); + assert_eq!(manifest.latest_head(), Some(&head)); + } + "ProviderGenerationExportChunk" => { + assert_eq!( + vector.dependencies, + [ + "account-genesis", + "authorized-event", + "checkpoint-direct", + "provider-generation-export-manifest" + ] + ); + let manifest_vector = vectors["provider-generation-export-manifest"]; + let manifest_bytes = fs::read(directory.join(&manifest_vector.canonical_file)).unwrap(); + let manifest = + ProviderGenerationExportManifest::from_canonical_bytes(&manifest_bytes).unwrap(); + let chunk = ProviderGenerationExportChunk::from_canonical_bytes(bytes).unwrap(); + let component = chunk.component().unwrap(); + let descriptor = manifest.descriptor(component).unwrap(); + assert_eq!(chunk.provider_id(), manifest.provider().id().unwrap()); + assert_eq!(chunk.log_id(), manifest.log_id()); + assert_eq!(chunk.key_version(), manifest.key_version()); + assert_eq!( + chunk.generation_commitment(), + manifest.generation_commitment() + ); + assert_eq!(descriptor.component().unwrap(), component); + assert_eq!(descriptor.chunk_count(), 1); + let chunk_commitment = chunk.commitment().unwrap(); + let chunk_list = provider_chunk_list_derivation( + "provider_generation_chunk_list_commitment", + "KRIKOS-ID/provider-generation-chunk-list/v1", + component.code(), + &[chunk_commitment], + ); + assert_eq!( + hex::encode(descriptor.chunk_list_commitment().as_bytes()), + chunk_list.expected_output_hex, + "generation descriptor must commit to the exact declared chunk" + ); + assert_eq!(chunk.ordinal(), 0); + assert_eq!(chunk.start_index(), 0); + assert_eq!(chunk.end_index(), descriptor.item_count()); + assert_eq!(chunk.item_payload_bytes(), descriptor.total_payload_bytes()); + + let genesis_vector = vectors["account-genesis"]; + let genesis_bytes = fs::read(directory.join(&genesis_vector.canonical_file)).unwrap(); + let genesis = AccountGenesis::from_canonical_bytes(&genesis_bytes).unwrap(); + let event_vector = vectors["authorized-event"]; + let event_bytes = fs::read(directory.join(&event_vector.canonical_file)).unwrap(); + let event = AuthorizedEvent::from_canonical_bytes(&event_bytes).unwrap(); + let checkpoint_vector = vectors["checkpoint-direct"]; + let checkpoint_bytes = + fs::read(directory.join(&checkpoint_vector.canonical_file)).unwrap(); + let checkpoint = SignedCheckpoint::from_canonical_bytes(&checkpoint_bytes).unwrap(); + let mirror: GenerationChunkMirror = postcard::from_bytes(bytes).unwrap(); + let items = chunk_items(&mirror.payload); + assert_eq!(items.len(), 1); + let item: ProviderCheckpointBundleItemMirror = postcard::from_bytes(&items[0]).unwrap(); + assert_eq!(item.bundle.genesis.as_ref(), Some(&genesis)); + assert_eq!(item.bundle.events, [event]); + assert_eq!(item.bundle.checkpoint, checkpoint); + assert_eq!(item.bundle.transition_event, None); + } + "ProviderAuditExportChunk" => { + assert_eq!(vector.dependencies, ["provider-audit-export-manifest"]); + let manifest_vector = vectors["provider-audit-export-manifest"]; + let manifest_bytes = fs::read(directory.join(&manifest_vector.canonical_file)).unwrap(); + let manifest = + ProviderAuditExportManifest::from_canonical_bytes(&manifest_bytes).unwrap(); + let chunk = ProviderAuditExportChunk::from_canonical_bytes(bytes).unwrap(); + assert_eq!(chunk.provider_id(), manifest.provider().id().unwrap()); + assert_eq!(chunk.log_id(), manifest.log_id()); + assert_eq!(chunk.audit_commitment(), manifest.audit_commitment()); + assert_eq!(manifest.chunk_count(), 1); + let chunk_commitment = chunk.commitment().unwrap(); + let chunk_list = provider_chunk_list_derivation( + "provider_audit_chunk_list_commitment", + "KRIKOS-ID/provider-audit-chunk-list/v1", + 0, + &[chunk_commitment], + ); + assert_eq!( + hex::encode(manifest.chunk_list_commitment().as_bytes()), + chunk_list.expected_output_hex, + "audit manifest must commit to the exact declared chunk" + ); + assert_eq!(chunk.ordinal(), 0); + assert_eq!(chunk.start_sequence(), 1); + assert_eq!( + chunk.end_sequence(), + manifest.record_count().checked_add(1).unwrap() + ); + assert_eq!(chunk.item_payload_bytes(), manifest.total_payload_bytes()); + } + "ProviderRecoveryExportManifest" => { + assert_eq!( + vector.dependencies, + [ + "provider-generation-export-manifest", + "provider-audit-export-manifest" + ] + ); + let generation_vector = vectors["provider-generation-export-manifest"]; + let generation_bytes = + fs::read(directory.join(&generation_vector.canonical_file)).unwrap(); + let generation = + ProviderGenerationExportManifest::from_canonical_bytes(&generation_bytes).unwrap(); + let audit_vector = vectors["provider-audit-export-manifest"]; + let audit_bytes = fs::read(directory.join(&audit_vector.canonical_file)).unwrap(); + let audit = ProviderAuditExportManifest::from_canonical_bytes(&audit_bytes).unwrap(); + let recovery = ProviderRecoveryExportManifest::from_canonical_bytes(bytes).unwrap(); + assert_eq!(recovery.generation(), &generation); + assert_eq!(recovery.audit(), &audit); + assert_eq!( + recovery.generation_manifest_commitment(), + generation.commitment().unwrap() + ); + assert_eq!( + recovery.audit_manifest_commitment(), + audit.commitment().unwrap() + ); + assert_eq!( + recovery.generation_commitment(), + generation.generation_commitment() + ); + assert_eq!(recovery.audit_commitment(), audit.audit_commitment()); + assert_eq!(recovery.artifact_commitment(), audit.artifact_commitment()); + } + "ProviderCompactionManifest" => { + assert_eq!(vector.dependencies, ["provider-recovery-export-manifest"]); + let recovery_vector = vectors["provider-recovery-export-manifest"]; + let recovery_bytes = fs::read(directory.join(&recovery_vector.canonical_file)).unwrap(); + let recovery = + ProviderRecoveryExportManifest::from_canonical_bytes(&recovery_bytes).unwrap(); + let compaction = ProviderCompactionManifest::from_canonical_bytes(bytes).unwrap(); + let generation = recovery.generation(); + assert_eq!( + compaction.provider_id(), + generation.provider().id().unwrap() + ); + assert_eq!(compaction.log_id(), generation.log_id()); + assert_eq!(compaction.key_version(), generation.key_version()); + assert_eq!(compaction.source_tree_size(), generation.tree_size()); + assert_eq!(compaction.source_tree_root(), generation.tree_root()); + assert_eq!( + compaction.archive_commitment(), + recovery.recovery_commitment() + ); + assert_eq!( + compaction.generation_commitment(), + recovery.generation_commitment() + ); + assert_eq!(compaction.audit_commitment(), recovery.audit_commitment()); + assert_eq!( + compaction.audit_artifact_commitment(), + recovery.artifact_commitment() + ); + } + "OpaqueProviderAnchorCommitment" => { + assert_eq!(vector.dependencies, ["provider-compaction-manifest"]); + let manifest_vector = vectors["provider-compaction-manifest"]; + let manifest_bytes = fs::read(directory.join(&manifest_vector.canonical_file)).unwrap(); + let manifest = + ProviderCompactionManifest::from_canonical_bytes(&manifest_bytes).unwrap(); + let anchor = OpaqueProviderAnchorCommitment::from_canonical_bytes(bytes).unwrap(); + assert_eq!( + anchor, + OpaqueProviderAnchorCommitment::from_compaction_manifest(&manifest).unwrap(), + "opaque anchor must commit to the exact declared compaction manifest" + ); + } + _ => assert!( + vector.dependencies.is_empty(), + "{} has declared dependencies but no source-owned semantic validator", + vector.name + ), + } +} + +fn digest_from_display(value: &str) -> Digest { + let hexadecimal = value + .strip_prefix("b3:") + .unwrap_or_else(|| panic!("unsupported digest display {value}")); + let bytes: [u8; 32] = hex::decode(hexadecimal).unwrap().try_into().unwrap(); + Digest::new(HashAlgorithm::Blake3_256, bytes) +} + +fn validate_tamper(vector: &VectorMetadata, bytes: &[u8], tamper: &TamperMetadata) { + assert!(!tamper.name.is_empty(), "tamper case name must be nonempty"); + let replacement = hex::decode(&tamper.replacement_hex).unwrap(); + assert_eq!(replacement.len(), 1, "tamper replacement is one byte"); + let mut changed = bytes.to_vec(); + let target = changed + .get_mut(tamper.offset) + .unwrap_or_else(|| panic!("{} tamper offset is out of bounds", vector.name)); + assert_ne!(*target, replacement[0], "tamper must change a byte"); + *target = replacement[0]; + assert_ne!( + blake3::hash(&changed), + blake3::hash(bytes), + "{} tamper did not change the canonical digest", + vector.name + ); + match tamper.expectation.as_str() { + "canonical_digest_mismatch" => {} + "signature_invalid_or_decode_rejected" => { + let changed_signature = match vector.wire_type.as_str() { + "SignedEventIntentApproval" => { + SignedEventIntentApproval::from_canonical_bytes(&changed) + .ok() + .map(|value| value.signatures()[0].signature().as_bytes().to_vec()) + } + "SignedControllerApproval" => { + SignedControllerApproval::from_canonical_bytes(&changed) + .ok() + .map(|value| value.signatures()[0].signature().as_bytes().to_vec()) + } + "SignedDelegation" => SignedDelegation::from_canonical_bytes(&changed) + .ok() + .map(|value| value.signature().as_bytes().to_vec()), + "SignedApplicationEvent" => SignedApplicationEvent::from_canonical_bytes(&changed) + .ok() + .map(|value| value.signature().as_bytes().to_vec()), + "SignedGuardianApproval" => SignedGuardianApproval::from_canonical_bytes(&changed) + .ok() + .map(|value| value.signature().as_bytes().to_vec()), + "SignedSocialAttestation" => { + SignedSocialAttestation::from_canonical_bytes(&changed) + .ok() + .map(|value| value.issuer_signature().as_bytes().to_vec()) + } + "SignedNameClaim" => SignedNameClaim::from_canonical_bytes(&changed) + .ok() + .map(|value| value.subject_signature().as_bytes().to_vec()), + "SignedPortableCredential" => { + SignedPortableCredential::from_canonical_bytes(&changed) + .ok() + .map(|value| value.issuer_signature().as_bytes().to_vec()) + } + "SignedProviderHead" => SignedProviderHead::from_canonical_bytes(&changed) + .ok() + .map(|value| value.signature().as_bytes().to_vec()), + _ => None, + }; + let changed_signature = changed_signature.unwrap_or_else(|| { + let end = tamper.offset.checked_add(1).unwrap(); + let start = end.checked_sub(64).unwrap_or_else(|| { + panic!("{} signature tamper does not cover 64 bytes", vector.name) + }); + changed[start..end].to_vec() + }); + let binding = vector + .signature_bindings + .first() + .expect("signature tamper requires a decoded-object signature binding"); + let message = hex::decode(&binding.message_hex).unwrap(); + let public_key: [u8; 32] = hex::decode(&binding.public_key_hex) + .unwrap() + .try_into() + .unwrap(); + let changed_signature = Signature::try_from(changed_signature.as_slice()).unwrap(); + assert!( + PublicKey::from_bytes(&public_key) + .unwrap() + .verify(&message, &changed_signature) + .is_err(), + "{} tampered signature still verified over the exact declared message", + vector.name + ); + } + "authentication_or_decode_rejected" => { + if let Ok(envelope) = BackupEnvelope::from_canonical_bytes(&changed) { + let passphrase = + BackupPassphrase::try_new(b"correct horse battery staple".to_vec()).unwrap(); + assert!( + envelope.restore(&passphrase).is_err(), + "{} tampered backup authenticated", + vector.name + ); + } + } + "private_metadata_authentication_rejected" => { + let envelope = PrivateMetadataEnvelope::from_canonical_bytes(&changed) + .expect("ciphertext tamper must retain a decodable envelope"); + let key = PrivateMetadataKey::try_new([0x31; 32]).unwrap(); + assert!( + envelope.open(&key).is_err(), + "{} tampered private metadata authenticated", + vector.name + ); + } + "cursor_authentication_rejected" => { + let cursor = SyncCursor::from_canonical_bytes(&changed) + .expect("cursor authenticator tamper must preserve canonical shape"); + assert!( + cursor + .verify(&CursorKey::new(INTEROP_SYNC_CURSOR_KEY).unwrap()) + .is_err(), + "{} tampered cursor authenticator verified", + vector.name + ); + } + "key_wrap_authentication_rejected" => { + let wrapped = WrappedGroupKey::from_canonical_bytes(&changed) + .expect("ciphertext tamper must retain a decodable group-key wrap"); + let recipient_secret = StaticSecret::from([0x20; 32]); + let ephemeral_public = + X25519PublicKey::from(*wrapped.header().ephemeral_public_key().as_bytes()); + let recipient_public = X25519PublicKey::from(&recipient_secret); + let shared = recipient_secret.diffie_hellman(&ephemeral_public); + let mut material = [0_u8; 96]; + material[..32].copy_from_slice(shared.as_bytes()); + material[32..64].copy_from_slice(ephemeral_public.as_bytes()); + material[64..].copy_from_slice(recipient_public.as_bytes()); + let key = blake3::derive_key("KRIKOS-ID/group-key-wrap-key/v1", &material); + let associated_data = + postcard::to_stdvec(&(wrapped.header(), wrapped.extensions())).unwrap(); + let cipher = XChaCha20Poly1305::new(&Key::from(key)); + assert!( + cipher + .decrypt( + &XNonce::from(*wrapped.header().nonce().as_bytes()), + Payload { + msg: wrapped.ciphertext(), + aad: &associated_data, + }, + ) + .is_err(), + "{} tampered key wrap authenticated", + vector.name + ); + } + "merkle_proof_rejected" => match vector.wire_type.as_str() { + "MerkleInclusionProof" => { + assert_eq!(vector.dependencies, ["merkle-set-leaf"]); + let leaf_bytes = fs::read(vector_directory().join("merkle-set-leaf.bin")).unwrap(); + let leaf = MerkleSetLeaf::from_canonical_bytes(&leaf_bytes).unwrap(); + let root = digest_from_display(&vector.expected_ids["merkle_root"]); + let proof = MerkleInclusionProof::from_canonical_bytes(&changed) + .expect("Merkle path tamper must retain canonical shape"); + assert!(proof.verify(&leaf, root).is_err()); + } + "MerkleConsistencyProof" => { + let old_root = digest_from_display(&vector.expected_ids["old_merkle_root"]); + let new_root = digest_from_display(&vector.expected_ids["new_merkle_root"]); + let proof = MerkleConsistencyProof::from_canonical_bytes(&changed) + .expect("Merkle path tamper must retain canonical shape"); + assert!(proof.verify(old_root, new_root).is_err()); + } + "MerkleNonMembershipProof" => { + let root = digest_from_display(&vector.expected_ids["merkle_root"]); + let key = MerkleSetKey::from_canonical_bytes( + &hex::decode(&vector.expected_ids["missing_key"]).unwrap(), + ) + .unwrap(); + let proof = MerkleNonMembershipProof::from_canonical_bytes(&changed) + .expect("Merkle path tamper must retain canonical shape"); + assert!(proof.verify(key, root).is_err()); + } + other => panic!("{} has invalid Merkle tamper type {other}", vector.name), + }, + "identifier_or_binding_rejected" => match vector.wire_type.as_str() { + "SignedCheckpoint" => { + if let Ok(value) = SignedCheckpoint::from_canonical_bytes(&changed) { + assert_ne!( + value.checkpoint_id().unwrap().to_string(), + vector.expected_ids["checkpoint_id"] + ); + } + } + "PairingTicket" => { + if let Ok(value) = PairingTicket::from_canonical_bytes(&changed) { + assert_ne!( + value.ticket_id().unwrap().as_digest().to_string(), + vector.expected_ids["pairing_ticket_id"] + ); + } + } + "PairingTranscript" => { + if let Ok(value) = PairingTranscript::from_canonical_bytes(&changed) { + assert_ne!( + value.transcript_id().unwrap().as_digest().to_string(), + vector.expected_ids["pairing_transcript_id"] + ); + } + } + "PairingConfirmationContext" => assert!( + PairingConfirmationContext::from_canonical_bytes(&changed).is_err(), + "{} changed confirmation context retained its transcript/SAS binding", + vector.name + ), + "DeviceAuthorizationProposal" => { + if let Ok(value) = DeviceAuthorizationProposal::from_canonical_bytes(&changed) { + assert_ne!( + value.proposal_id().unwrap().as_digest().to_string(), + vector.expected_ids["device_authorization_proposal_id"] + ); + } + } + other => panic!("{} has invalid identifier tamper type {other}", vector.name), + }, + other => panic!("{} has unknown tamper expectation {other}", vector.name), + } +} + +#[test] +fn authenticator_and_derivation_metadata_rejects_omission_and_reordering() { + let manifest = checked_in_manifest(); + let directory = vector_directory(); + let vectors = manifest + .vectors + .iter() + .map(|vector| (vector.name.as_str(), vector)) + .collect::>(); + + let recovery = vectors["provider-recovery-export-manifest"]; + let recovery_bytes = fs::read(directory.join(&recovery.canonical_file)).unwrap(); + let expected_signatures = expected_signature_bindings( + recovery, + &recovery_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ); + assert!(expected_signatures.len() >= 2); + let mut omitted_signature = (*recovery).clone(); + omitted_signature.signature_bindings.pop(); + assert!( + std::panic::catch_unwind(|| { + validate_signature_bindings( + &omitted_signature, + &recovery_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact signature validator must reject an omitted nested signature" + ); + let mut swapped_signatures = (*recovery).clone(); + swapped_signatures.signature_bindings.swap(0, 1); + assert!( + std::panic::catch_unwind(|| { + validate_signature_bindings( + &swapped_signatures, + &recovery_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact signature validator must reject reordered nested signatures" + ); + + let migration = vectors["crypto-migration-begin"]; + let migration_bytes = fs::read(directory.join(&migration.canonical_file)).unwrap(); + let expected_migration_signatures = expected_signature_bindings( + migration, + &migration_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ); + assert_eq!(expected_migration_signatures.len(), 2); + let mut omitted_migration_signature = (*migration).clone(); + omitted_migration_signature.signature_bindings.pop(); + assert!( + std::panic::catch_unwind(|| { + validate_signature_bindings( + &omitted_migration_signature, + &migration_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact signature validator must reject an omitted migration cross-signature" + ); + let mut swapped_migration_signatures = (*migration).clone(); + swapped_migration_signatures.signature_bindings.swap(0, 1); + assert!( + std::panic::catch_unwind(|| { + validate_signature_bindings( + &swapped_migration_signatures, + &migration_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact signature validator must reject reordered old/new migration signatures" + ); + + let recovery_anchor = vectors["recovery-delay-anchor"]; + let recovery_anchor_bytes = fs::read(directory.join(&recovery_anchor.canonical_file)).unwrap(); + let expected_recovery_signatures = expected_signature_bindings( + recovery_anchor, + &recovery_anchor_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ); + assert_eq!(expected_recovery_signatures.len(), 1); + let mut omitted_recovery_signature = (*recovery_anchor).clone(); + omitted_recovery_signature.signature_bindings.clear(); + assert!( + std::panic::catch_unwind(|| { + validate_signature_bindings( + &omitted_recovery_signature, + &recovery_anchor_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact signature validator must reject an omitted recovery receipt signature" + ); + let mut unrelated_recovery_signature = (*recovery_anchor).clone(); + unrelated_recovery_signature.signature_bindings[0] = + vectors["provider-receipts"].signature_bindings[0].clone(); + assert!( + std::panic::catch_unwind(|| { + validate_signature_bindings( + &unrelated_recovery_signature, + &recovery_anchor_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact signature validator must reject a same-provider signature from an unrelated recovery receipt" + ); + + let controller_approval = vectors["final-event-controller-approval"]; + let event_intent_approval = vectors["event-intent-approval"]; + assert_eq!(controller_approval.signature_bindings.len(), 1); + assert_eq!(event_intent_approval.signature_bindings.len(), 1); + assert_eq!( + controller_approval.signature_bindings[0].signer_key, + event_intent_approval.signature_bindings[0].signer_key, + "adversarial fixtures intentionally share one valid deterministic signer" + ); + let controller_approval_bytes = + fs::read(directory.join(&controller_approval.canonical_file)).unwrap(); + let mut unrelated_signature = (*controller_approval).clone(); + unrelated_signature.signature_bindings[0] = event_intent_approval.signature_bindings[0].clone(); + assert!( + std::panic::catch_unwind(|| { + validate_signature_bindings( + &unrelated_signature, + &controller_approval_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact signature validator must reject valid metadata from an unrelated signed vector" + ); + + let pairing = vectors["pairing-possession-proof"]; + let pairing_bytes = fs::read(directory.join(&pairing.canonical_file)).unwrap(); + let expected_macs = expected_mac_bindings( + pairing, + &pairing_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ); + assert_eq!(expected_macs.len(), 2); + let mut omitted_mac = (*pairing).clone(); + omitted_mac.mac_bindings.pop(); + assert!( + std::panic::catch_unwind(|| { + validate_mac_bindings( + &omitted_mac, + &pairing_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact MAC validator must reject an omitted authenticator" + ); + let mut swapped_macs = (*pairing).clone(); + swapped_macs.mac_bindings.swap(0, 1); + assert!( + std::panic::catch_unwind(|| { + validate_mac_bindings( + &swapped_macs, + &pairing_bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ) + }) + .is_err(), + "the exact MAC validator must reject reordered authenticators" + ); + + let event = vectors["authorized-event"]; + let event_bytes = fs::read(directory.join(&event.canonical_file)).unwrap(); + let authorized_event = AuthorizedEvent::from_canonical_bytes(&event_bytes).unwrap(); + let approval = &authorized_event.approvals().as_slice()[0]; + let keyed_signature = &approval.signatures()[0]; + let exact_binding = resolved_signature_binding_for_key( + "signature-1".to_owned(), + "KRIKOS-ID/controller-approval-signature/v1", + approval.body().to_canonical_bytes().unwrap(), + keyed_signature.signature().as_bytes(), + Some(ExactSigningKey::Controller( + keyed_signature.controller_key_id(), + )), + &manifest.deterministic_keys, + ); + assert_eq!(exact_binding, event.signature_bindings[0]); + let wrong_controller_key_id = manifest + .deterministic_keys + .iter() + .filter_map(metadata_signing_key) + .filter_map(|public| ControllerKeyId::for_signing_key(&public).ok()) + .find(|key_id| *key_id != keyed_signature.controller_key_id()) + .unwrap(); + let wrong_key_resolution = std::panic::catch_unwind(|| { + resolved_signature_binding_for_key( + "signature-1".to_owned(), + "KRIKOS-ID/controller-approval-signature/v1", + approval.body().to_canonical_bytes().unwrap(), + keyed_signature.signature().as_bytes(), + Some(ExactSigningKey::Controller(wrong_controller_key_id)), + &manifest.deterministic_keys, + ) + }); + assert!( + wrong_key_resolution.is_err(), + "signature resolution must reject a valid signature paired with a different controller key ID" + ); + + let event_expected_derivations = + expected_derivations(event, &event_bytes, &directory, &vectors); + assert!(event_expected_derivations.len() >= 2); + let mut omitted_derivation = (*event).clone(); + omitted_derivation.derivations.pop(); + assert!( + std::panic::catch_unwind(|| { + validate_derivations(&omitted_derivation, &event_bytes, &directory, &vectors) + }) + .is_err(), + "the exact derivation validator must reject an omitted nested derivation" + ); + let mut swapped_derivations = (*event).clone(); + swapped_derivations.derivations.swap(0, 1); + assert!( + std::panic::catch_unwind(|| { + validate_derivations(&swapped_derivations, &event_bytes, &directory, &vectors) + }) + .is_err(), + "the exact derivation validator must reject reordered nested derivations" + ); + + let migration_operation = vectors["account-operation-18"]; + let migration_operation_bytes = + fs::read(directory.join(&migration_operation.canonical_file)).unwrap(); + assert_eq!( + expected_derivations( + migration_operation, + &migration_operation_bytes, + &directory, + &vectors, + ) + .len(), + 1 + ); + let mut omitted_operation_derivation = (*migration_operation).clone(); + omitted_operation_derivation.derivations.clear(); + assert!( + std::panic::catch_unwind(|| { + validate_derivations( + &omitted_operation_derivation, + &migration_operation_bytes, + &directory, + &vectors, + ) + }) + .is_err(), + "the exact derivation validator must reject an omitted AccountOperation nested derivation" + ); + + let mut substituted_dependency = (*event).clone(); + substituted_dependency.dependencies[0] = "application-event-body".to_owned(); + assert!( + std::panic::catch_unwind(|| { + validate_cross_vector_dependencies( + &substituted_dependency, + &event_bytes, + &directory, + &vectors, + ) + }) + .is_err(), + "the exact dependency validator must reject substitution with an unrelated valid vector" + ); +} + +#[test] +fn checked_in_interop_catalog_is_complete_and_self_validating() { + let directory = vector_directory(); + let manifest_path = directory.join("manifest.json"); + let manifest_bytes = fs::read(&manifest_path).unwrap_or_else(|error| { + panic!( + "identity interop validation requires checked-in {}: {error}", + manifest_path.display() + ) + }); + let manifest: Manifest = serde_json::from_slice(&manifest_bytes).unwrap(); + assert_eq!( + serde_json::to_vec_pretty(&manifest).unwrap(), + manifest_bytes, + "manifest.json must remain in deterministic generator order and formatting" + ); + assert_eq!(manifest.format, "KRIKOS-ID interoperability vectors"); + assert_eq!(manifest.format_version, 2); + assert_eq!(manifest.binding_schema_version, 1); + assert_eq!(manifest.derivation_schema_version, 1); + assert!(manifest.canonical_profile.contains("Postcard 1.1.3")); + assert_eq!(manifest.algorithms.len(), 5); + assert_closed_inventory(&manifest); + assert!(!manifest.deterministic_keys.is_empty()); + let mut key_names = BTreeSet::new(); + let mut key_seeds = BTreeSet::new(); + let mut key_publics = BTreeSet::new(); + for key in &manifest.deterministic_keys { + assert!(!key.name.is_empty()); + assert!(matches!(key.algorithm.as_str(), "Ed25519" | "X25519")); + assert!( + key_names.insert(key.name.as_str()), + "duplicate test key name" + ); + assert!( + key_seeds.insert(key.test_only_secret_seed_hex.as_str()), + "duplicate test-only secret seed" + ); + assert!( + key_publics.insert(key.public_key_hex.as_str()), + "duplicate deterministic public key" + ); + let seed: [u8; 32] = hex::decode(&key.test_only_secret_seed_hex) + .unwrap() + .try_into() + .unwrap(); + let expected_public = hex::decode(&key.public_key_hex).unwrap(); + match key.algorithm.as_str() { + "Ed25519" => assert_eq!( + SecretKey::from_bytes(&seed).public().as_bytes().as_slice(), + expected_public.as_slice() + ), + "X25519" => assert_eq!( + X25519PublicKey::from(&StaticSecret::from(seed)) + .as_bytes() + .as_slice(), + expected_public.as_slice() + ), + _ => unreachable!(), + } + } + + let exclusions = manifest + .private_wire_exclusions + .iter() + .map(|exclusion| exclusion.wire_type.as_str()) + .collect::>(); + assert_eq!( + exclusions, + BTreeSet::from(["GuardianGrant", "GuardianGrantOpening"]) + ); + for exclusion in &manifest.private_wire_exclusions { + assert!(exclusion.reason.contains("private")); + assert!(exclusion.covered_by.contains("SignedGuardianApproval")); + } + + let mut names = BTreeSet::new(); + let mut files = BTreeSet::new(); + for vector in &manifest.vectors { + assert!(names.insert(vector.name.as_str()), "duplicate vector name"); + assert!( + files.insert(vector.canonical_file.as_str()), + "duplicate canonical fixture file" + ); + } + assert!( + manifest + .vectors + .windows(2) + .all(|pair| pair[0].name < pair[1].name), + "manifest vectors must remain in strict deterministic name order" + ); + let vectors = manifest + .vectors + .iter() + .map(|vector| (vector.name.as_str(), vector)) + .collect::>(); + for vector in &manifest.vectors { + let mut declared = BTreeSet::new(); + for dependency in &vector.dependencies { + assert_ne!(dependency, &vector.name, "vector cannot depend on itself"); + assert!( + declared.insert(dependency.as_str()), + "{} repeats dependency {dependency}", + vector.name + ); + let dependency_vector = vectors.get(dependency.as_str()).unwrap_or_else(|| { + panic!("{} names undeclared dependency {dependency}", vector.name) + }); + let dependency_bytes = + fs::read(directory.join(&dependency_vector.canonical_file)).unwrap(); + assert_eq!( + blake3::hash(&dependency_bytes).to_hex().as_str(), + dependency_vector.canonical_blake3_hex, + "{} dependency {dependency} is stale", + vector.name + ); + } + } + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + for name in &names { + visit_dependency_graph(name, &vectors, &mut visiting, &mut visited); + } + + for vector in &manifest.vectors { + assert!(!vector.algorithms.is_empty()); + validate_version_metadata(vector); + assert!( + vector.protocol_version.is_none() || vector.protocol_version == Some(1), + "{} has an unsupported protocol version", + vector.name + ); + assert_eq!(Path::new(&vector.canonical_file).components().count(), 1); + let bytes = fs::read(directory.join(&vector.canonical_file)).unwrap(); + assert_eq!(bytes.len(), vector.encoded_length, "{} length", vector.name); + assert_eq!( + hex::encode(&bytes), + vector.canonical_hex, + "{} hex", + vector.name + ); + assert_eq!( + blake3::hash(&bytes).to_hex().as_str(), + vector.canonical_blake3_hex, + "{} digest", + vector.name + ); + validate_wire_type(vector, &bytes); + validate_cross_vector_dependencies(vector, &bytes, &directory, &vectors); + validate_signature_bindings( + vector, + &bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ); + validate_mac_bindings( + vector, + &bytes, + &directory, + &vectors, + &manifest.deterministic_keys, + ); + for binding in &vector.signature_bindings { + assert!( + key_publics.contains(binding.public_key_hex.as_str()), + "{} signature public key lacks a test-only deterministic key declaration", + vector.name + ); + } + validate_derivations(vector, &bytes, &directory, &vectors); + validate_expected_ids(vector, &bytes); + assert!(!vector.tamper_cases.is_empty()); + let mut tamper_names = BTreeSet::new(); + let mut tamper_offsets = BTreeSet::new(); + for tamper in &vector.tamper_cases { + assert!( + tamper_names.insert(tamper.name.as_str()), + "{} has duplicate tamper case name", + vector.name + ); + assert!( + tamper_offsets.insert(tamper.offset), + "{} has duplicate tamper offset", + vector.name + ); + validate_tamper(vector, &bytes, tamper); + } + } + let checked_in_files = fs::read_dir(&directory) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .filter(|name| name.ends_with(".bin")) + .collect::>(); + assert_eq!( + checked_in_files, + files.into_iter().map(str::to_owned).collect(), + "every checked-in binary must appear exactly once in manifest.json" + ); +} diff --git a/protocols/krikos-identity/tests/key_rotation.rs b/protocols/krikos-identity/tests/key_rotation.rs new file mode 100644 index 00000000000..ec4e197de98 --- /dev/null +++ b/protocols/krikos-identity/tests/key_rotation.rs @@ -0,0 +1,1391 @@ +use std::convert::Infallible; + +use chacha20poly1305::{ + Key, XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, Payload}, +}; +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, AccountStore, ActivateCryptoMigration, + AdmissionEvidence, AgreementPublicKey, AgreementSecretKey, AlgorithmPublicKey, + AlgorithmSignature, ApplicationId, BeginCryptoMigration, BeginRecovery, CanonicalWire, + CheckpointId, ClaimEffects, ControlPolicy, ControllerApprovalBody, ControllerApprovals, + ControllerClass, ControllerDescriptor, ControllerKeyBinding, ControllerKeyBindingProof, + ControllerKeyBindingProofSet, ControllerKeyId, ControllerScope, ControllerSelector, + ControllerThreshold, ControllerWeight, CryptoMigrationBody, CryptoMigrationId, + CryptoSuiteDescriptor, DelayEvidence, DeviceAuthorization, DeviceClass, DeviceDescriptor, + DeviceId, Digest, DurationMillis, EndpointPublicKey, Epoch, EventBody, EventIntentApprovalBody, + EventIntentApprovals, EventPredecessors, Extension, Extensions, FreshnessEvidence, + FreshnessRequirement, GroupId, GroupKey, GroupKeyDistributionSnapshot, GroupKeyEpoch, + GroupKeyRotation, GroupKeyWrapHeader, HashAlgorithm, IdentityError, InclusionReceipt, + KeyWrapNonce, KeyedSignature, LeaseId, MemoryAccountStore, OperationKind, PolicyRule, + ProjectionEffect, ProjectionLifecycle, ProtocolMajor, ProtocolSignature, ProtocolUpgrade, + ProtocolVersion, ProviderDescriptor, ProviderHeadBody, ProviderKeyVersion, + ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, ProviderPolicy, ProviderPolicyVersion, + ProviderQuorum, ProviderReceipts, RecoveryAuthority, RecoveryAuthorityPlan, RecoveryPolicy, + RecoveryPolicyVersion, RecoveryProposal, RecoveryThresholdEvidence, RequiredWeight, + RetireAccount, RevokeDevice, Sequence, SignedControllerApproval, SignedEventIntentApproval, + SignedProviderHead, SigningPublicKey, SuspendDevice, Timestamp, UpgradeCompatibility, + WrappedGroupKey, rotate_group_key_with_rng, unwrap_group_key, +}; +use rand_core::{TryCryptoRng, TryRng}; +use x25519_dalek::{PublicKey, StaticSecret}; + +const KDF_CONTEXT: &str = "KRIKOS-ID/group-key-wrap-key/v1"; + +struct ScriptedRng { + bytes: Vec, + offset: usize, +} + +impl ScriptedRng { + fn new(bytes: Vec) -> Self { + Self { bytes, offset: 0 } + } +} + +impl TryRng for ScriptedRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + let mut bytes = [0; 4]; + self.try_fill_bytes(&mut bytes)?; + Ok(u32::from_le_bytes(bytes)) + } + + fn try_next_u64(&mut self) -> Result { + let mut bytes = [0; 8]; + self.try_fill_bytes(&mut bytes)?; + Ok(u64::from_le_bytes(bytes)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + let end = self + .offset + .checked_add(destination.len()) + .expect("test vector must contain every requested random byte"); + destination.copy_from_slice(&self.bytes[self.offset..end]); + self.offset = end; + Ok(()) + } +} + +impl TryCryptoRng for ScriptedRng {} + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +fn digest(seed: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [seed; 32]) +} + +fn typed_id(seed: u8) -> T { + T::from_canonical_bytes(&digest(seed).to_canonical_bytes().unwrap()).unwrap() +} + +fn signing_key(seed: u8) -> SigningPublicKey { + let secret = SecretKey::from_bytes(&[seed; 32]); + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap() +} + +fn authorization(secret: &AgreementSecretKey, index: usize, epoch: u64) -> DeviceAuthorization { + let index = u8::try_from(index).unwrap(); + let signing_seed = 0x40_u8.checked_add(index.checked_mul(2).unwrap()).unwrap(); + let endpoint_seed = signing_seed.checked_add(1).unwrap(); + let descriptor = DeviceDescriptor::new( + signing_key(signing_seed), + secret.public_key().unwrap(), + EndpointPublicKey::new(signing_key(endpoint_seed)), + Extensions::default(), + ) + .unwrap(); + DeviceAuthorization::new( + descriptor.id().unwrap(), + descriptor, + DeviceClass::ApplicationOnly, + None, + Vec::new(), + Epoch::new(epoch), + Extensions::default(), + ) + .unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn rule(operation: OperationKind) -> PolicyRule { + PolicyRule::new( + operation, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap() +} + +fn genesis_with_operations( + operations: Vec, + provider_policy: ProviderPolicy, +) -> (AccountState, SecretKey) { + let signer = SecretKey::from_bytes(&[7; 32]); + let control_policy = ControlPolicy::new( + operations.into_iter().map(rule).collect(), + Extensions::default(), + ) + .unwrap(); + let recovery_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x81; 32], + Timestamp::from_unix_millis(1), + control_policy, + vec![controller(&signer)], + recovery_policy, + provider_policy, + Extensions::default(), + ) + .unwrap(); + (AccountState::from_genesis(&genesis).unwrap(), signer) +} + +fn genesis() -> (AccountState, SecretKey) { + genesis_with_operations( + vec![ + OperationKind::AuthorizeDevice, + OperationKind::SuspendDevice, + OperationKind::RevokeDevice, + ], + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + ) +} + +fn lifecycle_genesis() -> (AccountState, SecretKey) { + let provider_secret = SecretKey::from_bytes(&[99; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + genesis_with_operations( + vec![ + OperationKind::AuthorizeDevice, + OperationKind::SuspendDevice, + OperationKind::RevokeDevice, + OperationKind::BeginRecovery, + OperationKind::BeginCryptoMigration, + OperationKind::ActivateCryptoMigration, + OperationKind::UpgradeProtocol, + OperationKind::RetireAccount, + ], + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(), + ) +} + +fn recovery_intent_approvals( + state: &AccountState, + body: &EventBody, + signer: &SecretKey, +) -> EventIntentApprovals { + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let approval_body = EventIntentApprovalBody::new( + state.active_controllers()[0].id(), + body.proposal_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let keyed_signature = KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + ); + EventIntentApprovals::new(vec![ + SignedEventIntentApproval::new(approval_body, vec![keyed_signature]).unwrap(), + ]) + .unwrap() +} + +fn recovery_observation_receipt(state: &AccountState, body: &EventBody) -> InclusionReceipt { + let provider_secret = SecretKey::from_bytes(&[99; 32]); + let provider = match state.provider_policy().mode() { + krikos_identity::ProviderMode::LocalOnly => { + panic!("recovery lifecycle fixture uses a replicated provider") + } + krikos_identity::ProviderMode::Replicated(policy) => &policy.providers()[0], + }; + let log_id = typed_id::(0x67); + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + log_id, + state.account_id(), + ProviderLogSubject::EventIntent(body.proposal_id().unwrap()), + Timestamp::from_unix_millis(100), + Extensions::default(), + ) + .unwrap(); + let head = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + 1, + entry.merkle_leaf_hash().unwrap(), + Timestamp::from_unix_millis(100), + Extensions::default(), + ) + .unwrap(); + let signature = provider_secret.sign(&head.signing_bytes().unwrap()); + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(head, ProtocolSignature::ed25519(signature.to_bytes())), + ) + .unwrap() +} + +fn authorize_body( + state: &AccountState, + body: EventBody, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let checkpoint_id = typed_id::(0x44); + let delay = if matches!(body.operation(), AccountOperation::BeginRecovery(_)) { + DelayEvidence::provider_quorum( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + recovery_intent_approvals(state, &body, signer), + ProviderReceipts::new(vec![recovery_observation_receipt(state, &body)]).unwrap(), + ) + .unwrap() + } else { + DelayEvidence::none() + }; + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + delay, + Extensions::default(), + ) + .unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + let approval_body = ControllerApprovalBody::event( + state.active_controllers()[0].id(), + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let keyed_signature = KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key( + &SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(), + ) + .unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + ); + let approval = SignedControllerApproval::new(approval_body, vec![keyed_signature]).unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn authorized_event( + state: &AccountState, + operation: AccountOperation, + resulting_epoch: Epoch, + nonce: u8, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + resulting_epoch, + predecessors, + operation, + Timestamp::from_unix_millis(u64::from(nonce)), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + authorize_body(state, body, signer) +} + +fn project_authorizations( + mut state: AccountState, + signer: SecretKey, + authorizations: &[DeviceAuthorization], +) -> (AccountState, SecretKey) { + for (index, authorization) in authorizations.iter().enumerate() { + let epoch = u64::try_from(index).unwrap().checked_add(1).unwrap(); + assert_eq!(authorization.authorization_epoch(), Epoch::new(epoch)); + let event = authorized_event( + &state, + AccountOperation::AuthorizeDevice(authorization.clone()), + Epoch::new(epoch), + u8::try_from(index).unwrap().checked_add(10).unwrap(), + &signer, + ); + state.validate_and_apply(&event).unwrap(); + } + (state, signer) +} + +fn active_state(authorizations: &[DeviceAuthorization]) -> (AccountState, SecretKey) { + let (state, signer) = genesis(); + project_authorizations(state, signer, authorizations) +} + +fn lifecycle_active_state(authorizations: &[DeviceAuthorization]) -> (AccountState, SecretKey) { + let (state, signer) = lifecycle_genesis(); + project_authorizations(state, signer, authorizations) +} + +fn begin_recovery_operation(state: &AccountState, signer: &SecretKey) -> AccountOperation { + let retained_devices = state.devices().iter().map(|device| device.id()).collect(); + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0x51; 32], + vec![controller(signer)], + state.control_policy().clone(), + state.recovery_policy().clone(), + retained_devices, + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap(); + AccountOperation::BeginRecovery( + BeginRecovery::try_new( + ProtocolVersion::V1, + proposal, + RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ), + Extensions::default(), + ) + .unwrap(), + ) +} + +fn begin_migration_operation( + state: &AccountState, + signer: &SecretKey, +) -> (AccountOperation, CryptoMigrationId) { + let v1_suite = CryptoSuiteDescriptor::v1().unwrap(); + let candidate_suite = CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 2, + v1_suite.hash_algorithm_code(), + v1_suite.signature_algorithm_code(), + v1_suite.agreement_algorithm_code(), + v1_suite.kdf_algorithm_code(), + v1_suite.aead_algorithm_code(), + Extensions::default(), + ) + .unwrap(); + let migrated_signer = SecretKey::from_bytes(&[0x91; 32]); + let controller = &state.active_controllers()[0]; + let migrated_key = AlgorithmPublicKey::new( + candidate_suite.signature_algorithm_code(), + migrated_signer.public().as_bytes().to_vec(), + ) + .unwrap(); + let migration = CryptoMigrationBody::try_new( + ProtocolVersion::V1, + state.account_id(), + v1_suite.crypto_suite_id().unwrap(), + candidate_suite, + vec![ + ControllerKeyBinding::try_new( + controller.id(), + ControllerKeyId::for_signing_key(&controller.signing_key()).unwrap(), + migrated_key, + Extensions::default(), + ) + .unwrap(), + ], + None, + [0x92; 32], + Extensions::default(), + ) + .unwrap(); + let migration_id = migration.crypto_migration_id().unwrap(); + let message = migration_id.to_canonical_bytes().unwrap(); + let proof = ControllerKeyBindingProof::try_new( + migration_id, + controller.id(), + AlgorithmSignature::new( + v1_suite.signature_algorithm_code(), + signer.sign(&message).to_bytes().to_vec(), + ) + .unwrap(), + AlgorithmSignature::new( + v1_suite.signature_algorithm_code(), + migrated_signer.sign(&message).to_bytes().to_vec(), + ) + .unwrap(), + ) + .unwrap(); + ( + AccountOperation::BeginCryptoMigration( + BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + ControllerKeyBindingProofSet::try_new(vec![proof]).unwrap(), + Extensions::default(), + ) + .unwrap(), + ), + migration_id, + ) +} + +fn snapshot(state: &AccountState, recipients: Vec) -> GroupKeyDistributionSnapshot { + GroupKeyDistributionSnapshot::from_post_state( + state, + ApplicationId::new(digest(0xa1)), + GroupId::new(digest(0xb2)), + GroupKeyEpoch::new(3), + recipients, + ) + .unwrap() +} + +#[test] +fn fixed_v1_reference_stages_and_round_trip_are_frozen() { + let recipient_secret = AgreementSecretKey::from_bytes([0x20; 32]); + let recipient = authorization(&recipient_secret, 0, 1); + let (state, _) = active_state(std::slice::from_ref(&recipient)); + let snapshot = snapshot(&state, vec![recipient.device_id()]); + let group_key = GroupKey::new([0x90; 32]); + let random_bytes: Vec = (0x40_u8..=0x77).collect(); + let mut random = ScriptedRng::new(random_bytes.clone()); + + let rotation = rotate_group_key_with_rng(&snapshot, &group_key, &mut random).unwrap(); + let wrapped = &rotation.recipient_key_wraps().as_slice()[0]; + + let ephemeral_secret_bytes: [u8; 32] = random_bytes[..32].try_into().unwrap(); + let nonce_bytes: [u8; 24] = random_bytes[32..].try_into().unwrap(); + let reference_ephemeral_secret = StaticSecret::from(ephemeral_secret_bytes); + let reference_ephemeral_public = PublicKey::from(&reference_ephemeral_secret); + let reference_recipient_public = + PublicKey::from(*recipient.descriptor().agreement_key().as_bytes()); + let reference_shared = reference_ephemeral_secret.diffie_hellman(&reference_recipient_public); + let mut kdf_material = [0_u8; 96]; + kdf_material[..32].copy_from_slice(reference_shared.as_bytes()); + kdf_material[32..64].copy_from_slice(reference_ephemeral_public.as_bytes()); + kdf_material[64..].copy_from_slice(reference_recipient_public.as_bytes()); + let reference_key = blake3::derive_key(KDF_CONTEXT, &kdf_material); + let reference_aad = postcard::to_stdvec(&(wrapped.header(), wrapped.extensions())).unwrap(); + let reference_cipher = XChaCha20Poly1305::new(&Key::from(reference_key)); + let reference_ciphertext = reference_cipher + .encrypt( + &XNonce::from(nonce_bytes), + Payload { + msg: group_key.as_bytes(), + aad: &reference_aad, + }, + ) + .unwrap(); + + assert_eq!( + hex::encode(reference_ephemeral_public.as_bytes()), + "79a631eede1bf9c98f12032cdeadd0e7a079398fc786b88cc846ec89af85a51a" + ); + assert_eq!( + hex::encode(reference_shared.as_bytes()), + "e711c769e2ffcffd4138bd1c9a98edc4d4e4eb2387a3bacaaa83c4cdbea7c86f" + ); + assert_eq!( + hex::encode(reference_key), + "f9bb12d93810013a19fece5587d69c754a58d114374b2508567164b044601bee" + ); + assert_eq!( + hex::encode(&reference_aad), + "01018ff40ee1a62f16342b90d738eb35827198fecb38c8b8cef4e949427a1d7b27ea0117206292de38719a908ec5fcbdcbfaa5cd396a56df6b8b5237ecd3855149883101a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a101b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b20103012b14395570cbf755d9df2a69b1b9362cb3a5ef7934aae464aea17f11dbc53c2301ecc22ebef4b41a173f3763d2bea3bb274a8e4cd64103196f9da03efadc5ab1d40179a631eede1bf9c98f12032cdeadd0e7a079398fc786b88cc846ec89af85a51a606162636465666768696a6b6c6d6e6f70717273747576770000" + ); + assert_eq!( + hex::encode(&reference_ciphertext), + "889a9197793033a7da7d80f78aaba54868df0f6c5a0a523982aa9bec72e6a4f92281cbb69471263eb0a5958572da5626" + ); + assert_eq!(wrapped.ciphertext(), reference_ciphertext); + assert_eq!( + wrapped.group_key_wrap_id().unwrap().to_string(), + "b3:4a4f13c30b1c657bf2f97a4e1207383c0b4940b18d913d221983691c078fbe1c" + ); + let unwrapped = unwrap_group_key(&snapshot, wrapped, &recipient_secret).unwrap(); + assert_eq!(unwrapped.as_bytes(), group_key.as_bytes()); + assert_eq!(format!("{group_key:?}"), "GroupKey()"); + assert_eq!( + format!("{recipient_secret:?}"), + "AgreementSecretKey()" + ); +} + +#[test] +fn unwrap_rejects_wrong_secret_ciphertext_and_header_or_outer_substitution() { + let first_secret = AgreementSecretKey::from_bytes([0x21; 32]); + let second_secret = AgreementSecretKey::from_bytes([0x22; 32]); + let first = authorization(&first_secret, 0, 1); + let second = authorization(&second_secret, 1, 2); + let (state, _) = active_state(&[first.clone(), second.clone()]); + let snapshot = snapshot(&state, vec![first.device_id(), second.device_id()]); + let mut random = ScriptedRng::new((0_u8..112).collect()); + let rotation = + rotate_group_key_with_rng(&snapshot, &GroupKey::new([0x44; 32]), &mut random).unwrap(); + let wrapped = rotation + .recipient_key_wraps() + .as_slice() + .iter() + .find(|wrapped| wrapped.recipient_device_id() == first.device_id()) + .unwrap(); + + assert!(matches!( + unwrap_group_key(&snapshot, wrapped, &second_secret), + Err(IdentityError::KeyWrapAuthenticationFailed) + )); + + let mut tampered_ciphertext = wrapped.ciphertext().to_vec(); + tampered_ciphertext[0] ^= 0x80; + let tampered = WrappedGroupKey::new( + wrapped.header().clone(), + tampered_ciphertext, + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + unwrap_group_key(&snapshot, &tampered, &first_secret), + Err(IdentityError::KeyWrapAuthenticationFailed) + )); + + let substituted_outer_extensions = + Extensions::new(vec![Extension::new(90, false, vec![1, 2, 3]).unwrap()]).unwrap(); + let substituted_outer = WrappedGroupKey::new( + wrapped.header().clone(), + wrapped.ciphertext().to_vec(), + substituted_outer_extensions, + ) + .unwrap(); + assert!(matches!( + unwrap_group_key(&snapshot, &substituted_outer, &first_secret), + Err(IdentityError::KeyWrapAuthenticationFailed) + )); + + let substituted_header = GroupKeyWrapHeader::new_for_recipient( + snapshot.crypto_suite_id(), + snapshot.account_id(), + snapshot.application_id(), + snapshot.group_id(), + snapshot.authorizing_account_epoch(), + snapshot.group_key_epoch(), + &second, + wrapped.header().ephemeral_public_key(), + wrapped.header().nonce(), + Extensions::default(), + ) + .unwrap(); + let substituted = WrappedGroupKey::new( + substituted_header, + wrapped.ciphertext().to_vec(), + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + unwrap_group_key(&snapshot, &substituted, &second_secret), + Err(IdentityError::KeyWrapAuthenticationFailed) + )); +} + +#[test] +fn constructors_reject_low_order_keys_unsupported_suites_and_non_48_byte_ciphertexts() { + assert!(matches!( + AgreementPublicKey::x25519([0; 32]), + Err(IdentityError::InvalidPublicKey { .. }) + )); + + let recipient_secret = AgreementSecretKey::from_bytes([0x23; 32]); + let recipient = authorization(&recipient_secret, 0, 1); + let (state, _) = active_state(std::slice::from_ref(&recipient)); + let snapshot = snapshot(&state, vec![recipient.device_id()]); + let unsupported = CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 2, + 1, + 1, + 1, + 1, + 1, + Extensions::default(), + ) + .unwrap() + .crypto_suite_id() + .unwrap(); + assert!(matches!( + GroupKeyWrapHeader::new_for_recipient( + unsupported, + snapshot.account_id(), + snapshot.application_id(), + snapshot.group_id(), + snapshot.authorizing_account_epoch(), + snapshot.group_key_epoch(), + &recipient, + AgreementSecretKey::from_bytes([0x67; 32]) + .public_key() + .unwrap(), + KeyWrapNonce::new([0x68; 24]), + Extensions::default(), + ), + Err(IdentityError::UnsupportedKeyWrapSuite) + )); + + let mut random = ScriptedRng::new(vec![0x69; 56]); + let rotation = + rotate_group_key_with_rng(&snapshot, &GroupKey::new([0x6a; 32]), &mut random).unwrap(); + let valid = &rotation.recipient_key_wraps().as_slice()[0]; + + let unsupported_header_wire = postcard::to_stdvec(&( + ProtocolVersion::V1, + unsupported, + valid.header().account_id(), + valid.header().application_id(), + valid.header().group_id(), + valid.header().authorizing_account_epoch(), + valid.header().group_key_epoch(), + valid.header().recipient_device_id(), + valid.header().recipient_agreement_key_id(), + valid.header().ephemeral_public_key(), + valid.header().nonce(), + Extensions::default(), + )) + .unwrap(); + assert!(GroupKeyWrapHeader::from_canonical_bytes(&unsupported_header_wire).is_err()); + + for length in [0, 47, 49, 4096] { + assert!( + WrappedGroupKey::new( + valid.header().clone(), + vec![0; length], + Extensions::default(), + ) + .is_err() + ); + } + for length in [47, 49] { + let invalid_wrap_wire = postcard::to_stdvec(&( + valid.header().clone(), + vec![0_u8; length], + Extensions::default(), + )) + .unwrap(); + assert!(WrappedGroupKey::from_canonical_bytes(&invalid_wrap_wire).is_err()); + } +} + +#[test] +fn snapshot_derives_authority_and_binds_complete_application_membership() { + let first_secret = AgreementSecretKey::from_bytes([0x24; 32]); + let second_secret = AgreementSecretKey::from_bytes([0x25; 32]); + let third_secret = AgreementSecretKey::from_bytes([0x26; 32]); + let first = authorization(&first_secret, 0, 1); + let second = authorization(&second_secret, 1, 2); + let third = authorization(&third_secret, 2, 3); + let (active, signer) = active_state(&[first.clone(), second.clone(), third.clone()]); + + let expected = vec![second.device_id(), first.device_id()]; + let snapshot = snapshot(&active, expected); + let mut expected_sorted = vec![first.device_id(), second.device_id()]; + expected_sorted.sort_unstable(); + assert_eq!( + snapshot.expected_recipient_ids().collect::>(), + expected_sorted + ); + assert_eq!(snapshot.account_revision(), &active.revision_token()); + assert_eq!(snapshot.account_id(), active.account_id()); + assert_eq!(snapshot.authorizing_account_epoch(), active.epoch()); + + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &active, + snapshot.application_id(), + snapshot.group_id(), + snapshot.group_key_epoch(), + vec![first.device_id(), first.device_id()], + ), + Err(IdentityError::DuplicateElement { .. }) + )); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &active, + snapshot.application_id(), + snapshot.group_id(), + snapshot.group_key_epoch(), + vec![typed_id::(0xee)], + ), + Err(IdentityError::DeviceNotAuthorized) + )); + assert!( + !snapshot + .expected_recipient_ids() + .any(|device_id| device_id == third.device_id()) + ); + + let mut suspended = active.clone(); + let suspend = authorized_event( + &suspended, + AccountOperation::SuspendDevice( + SuspendDevice::new(second.device_id(), Extensions::default()).unwrap(), + ), + Epoch::new(4), + 40, + &signer, + ); + suspended.validate_and_apply(&suspend).unwrap(); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &suspended, + snapshot.application_id(), + snapshot.group_id(), + snapshot.group_key_epoch(), + vec![second.device_id()], + ), + Err(IdentityError::DeviceSuspended) + )); + + let mut revoked = active; + let revoke = authorized_event( + &revoked, + AccountOperation::RevokeDevice( + RevokeDevice::new(third.device_id(), None, Extensions::default()).unwrap(), + ), + Epoch::new(4), + 41, + &signer, + ); + revoked.validate_and_apply(&revoke).unwrap(); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &revoked, + snapshot.application_id(), + snapshot.group_id(), + snapshot.group_key_epoch(), + vec![third.device_id()], + ), + Err(IdentityError::DeviceRevoked) + )); +} + +#[test] +fn snapshot_account_lifecycle_gate_covers_every_projection_state() { + let first_secret = AgreementSecretKey::from_bytes([0x27; 32]); + let second_secret = AgreementSecretKey::from_bytes([0x28; 32]); + let first = authorization(&first_secret, 0, 1); + let second = authorization(&second_secret, 1, 2); + let (active, signer) = lifecycle_active_state(&[first.clone(), second.clone()]); + assert_eq!(active.lifecycle(), ProjectionLifecycle::Active); + snapshot(&active, vec![first.device_id()]); + + let mut recovery_pending = active.clone(); + let begin_recovery = authorized_event( + &recovery_pending, + begin_recovery_operation(&recovery_pending, &signer), + recovery_pending.epoch().checked_next().unwrap(), + 0xa1, + &signer, + ); + recovery_pending + .validate_and_apply(&begin_recovery) + .unwrap(); + assert_eq!( + recovery_pending.lifecycle(), + ProjectionLifecycle::RecoveryPending + ); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &recovery_pending, + ApplicationId::new(digest(0xa1)), + GroupId::new(digest(0xb2)), + GroupKeyEpoch::new(3), + vec![first.device_id()], + ), + Err(IdentityError::RecoveryPending) + )); + + let mut migration_pending = active.clone(); + let (begin_migration, migration_id) = begin_migration_operation(&migration_pending, &signer); + let begin_migration = authorized_event( + &migration_pending, + begin_migration, + migration_pending.epoch(), + 0xa2, + &signer, + ); + let begin_migration_event_id = begin_migration.event_id().unwrap(); + migration_pending + .validate_and_apply(&begin_migration) + .unwrap(); + assert_eq!( + migration_pending.lifecycle(), + ProjectionLifecycle::MigrationPending + ); + snapshot(&migration_pending, vec![first.device_id()]); + + let mut migration_dual = migration_pending; + let activate_migration = authorized_event( + &migration_dual, + AccountOperation::ActivateCryptoMigration( + ActivateCryptoMigration::try_new( + ProtocolVersion::V1, + migration_id, + begin_migration_event_id, + Extensions::default(), + ) + .unwrap(), + ), + migration_dual.epoch().checked_next().unwrap(), + 0xa3, + &signer, + ); + migration_dual + .validate_and_apply(&activate_migration) + .unwrap(); + assert_eq!( + migration_dual.lifecycle(), + ProjectionLifecycle::MigrationDual + ); + snapshot(&migration_dual, vec![first.device_id()]); + + let mut upgrade_pending = active.clone(); + let upgrade = authorized_event( + &upgrade_pending, + AccountOperation::UpgradeProtocol( + ProtocolUpgrade::try_new( + ProtocolVersion::V1, + ProtocolMajor::new(1).unwrap(), + ProtocolMajor::new(2).unwrap(), + digest(0xa4), + UpgradeCompatibility::OldClientsReadOnly, + None, + Extensions::default(), + ) + .unwrap(), + ), + upgrade_pending.epoch().checked_next().unwrap(), + 0xa4, + &signer, + ); + upgrade_pending.validate_and_apply(&upgrade).unwrap(); + assert_eq!( + upgrade_pending.lifecycle(), + ProjectionLifecycle::UpgradePending + ); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &upgrade_pending, + ApplicationId::new(digest(0xa1)), + GroupId::new(digest(0xb2)), + GroupKeyEpoch::new(3), + vec![first.device_id()], + ), + Err(IdentityError::ProtocolUpgradeReadOnly) + )); + + let mut retired = active.clone(); + let retirement = authorized_event( + &retired, + AccountOperation::RetireAccount( + RetireAccount::try_new(ProtocolVersion::V1, None, None, Extensions::default()).unwrap(), + ), + retired.epoch().checked_next().unwrap(), + 0xa5, + &signer, + ); + retired.validate_and_apply(&retirement).unwrap(); + assert_eq!(retired.lifecycle(), ProjectionLifecycle::Retired); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &retired, + ApplicationId::new(digest(0xa1)), + GroupId::new(digest(0xb2)), + GroupKeyEpoch::new(3), + vec![first.device_id()], + ), + Err(IdentityError::AccountRetired) + )); + + let fork_pre_state = active; + let left = authorized_event( + &fork_pre_state, + AccountOperation::SuspendDevice( + SuspendDevice::new(second.device_id(), Extensions::default()).unwrap(), + ), + fork_pre_state.epoch().checked_next().unwrap(), + 0xa6, + &signer, + ); + let right = authorized_event( + &fork_pre_state, + AccountOperation::RevokeDevice( + RevokeDevice::new(second.device_id(), None, Extensions::default()).unwrap(), + ), + fork_pre_state.epoch().checked_next().unwrap(), + 0xa7, + &signer, + ); + let mut forked = fork_pre_state; + forked.validate_and_apply(&left).unwrap(); + forked.validate_and_apply(&right).unwrap(); + assert_eq!(forked.lifecycle(), ProjectionLifecycle::Forked); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &forked, + ApplicationId::new(digest(0xa1)), + GroupId::new(digest(0xb2)), + GroupKeyEpoch::new(3), + vec![first.device_id()], + ), + Err(IdentityError::AccountForked) + )); +} + +#[test] +fn rotation_artifact_rejects_stale_and_forked_persistence_revisions() { + let first_secret = AgreementSecretKey::from_bytes([0x29; 32]); + let second_secret = AgreementSecretKey::from_bytes([0x2a; 32]); + let first = authorization(&first_secret, 0, 1); + let second = authorization(&second_secret, 1, 2); + let (active, signer) = active_state(&[first.clone(), second.clone()]); + let old_snapshot = snapshot(&active, vec![first.device_id()]); + let old_revision = active.revision_token(); + let artifact: GroupKeyRotation = rotate_group_key_with_rng( + &old_snapshot, + &GroupKey::new([0x72; 32]), + &mut ScriptedRng::new(vec![0x73; 56]), + ) + .unwrap(); + assert_eq!(artifact.account_revision(), &old_revision); + artifact.validate_current_revision(&active).unwrap(); + + let mut advanced = active.clone(); + let suspend_other = authorized_event( + &advanced, + AccountOperation::SuspendDevice( + SuspendDevice::new(second.device_id(), Extensions::default()).unwrap(), + ), + advanced.epoch().checked_next().unwrap(), + 0xb1, + &signer, + ); + advanced.validate_and_apply(&suspend_other).unwrap(); + assert_eq!(artifact.account_revision(), &old_revision); + assert!(matches!( + artifact.validate_current_revision(&advanced), + Err(IdentityError::StaleRevision) + )); + + let current_snapshot = snapshot(&advanced, vec![first.device_id()]); + let current_artifact = rotate_group_key_with_rng( + ¤t_snapshot, + &GroupKey::new([0x74; 32]), + &mut ScriptedRng::new(vec![0x75; 56]), + ) + .unwrap(); + current_artifact + .validate_current_revision(&advanced) + .unwrap(); + assert_ne!( + artifact.account_revision(), + current_artifact.account_revision() + ); + + let suspend_recipient = authorized_event( + &advanced, + AccountOperation::SuspendDevice( + SuspendDevice::new(first.device_id(), Extensions::default()).unwrap(), + ), + advanced.epoch().checked_next().unwrap(), + 0xb2, + &signer, + ); + advanced.validate_and_apply(&suspend_recipient).unwrap(); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &advanced, + old_snapshot.application_id(), + old_snapshot.group_id(), + old_snapshot.group_key_epoch(), + vec![first.device_id()], + ), + Err(IdentityError::DeviceSuspended) + )); + + let left = authorized_event( + &active, + AccountOperation::SuspendDevice( + SuspendDevice::new(second.device_id(), Extensions::default()).unwrap(), + ), + active.epoch().checked_next().unwrap(), + 0xb3, + &signer, + ); + let right = authorized_event( + &active, + AccountOperation::RevokeDevice( + RevokeDevice::new(second.device_id(), None, Extensions::default()).unwrap(), + ), + active.epoch().checked_next().unwrap(), + 0xb4, + &signer, + ); + let mut forked = active; + forked.validate_and_apply(&left).unwrap(); + forked.validate_and_apply(&right).unwrap(); + assert!(matches!( + artifact.validate_current_revision(&forked), + Err(IdentityError::AccountForked) + )); + assert!(matches!( + GroupKeyDistributionSnapshot::from_post_state( + &forked, + old_snapshot.application_id(), + old_snapshot.group_id(), + old_snapshot.group_key_epoch(), + vec![first.device_id()], + ), + Err(IdentityError::AccountForked) + )); +} + +#[test] +fn rotation_output_exactly_matches_snapshot_and_rejects_randomness_reuse() { + let first_secret = AgreementSecretKey::from_bytes([0x31; 32]); + let second_secret = AgreementSecretKey::from_bytes([0x32; 32]); + let first = authorization(&first_secret, 0, 1); + let second = authorization(&second_secret, 1, 2); + let (state, _) = active_state(&[first.clone(), second.clone()]); + let snapshot = snapshot(&state, vec![second.device_id(), first.device_id()]); + let group_key = GroupKey::new([0x77; 32]); + + assert!(matches!( + rotate_group_key_with_rng(&snapshot, &group_key, &mut RepeatingRng(5)), + Err(IdentityError::DuplicateElement { .. }) + )); + + let mut repeated_ephemeral = vec![0x39; 32]; + repeated_ephemeral.extend_from_slice(&[0x41; 24]); + repeated_ephemeral.extend_from_slice(&[0x39; 32]); + repeated_ephemeral.extend_from_slice(&[0x42; 24]); + let repeated_ephemeral_result = rotate_group_key_with_rng( + &snapshot, + &group_key, + &mut ScriptedRng::new(repeated_ephemeral), + ); + assert!( + matches!( + &repeated_ephemeral_result, + Err(IdentityError::DuplicateElement { + resource: "recipient key wrap ephemeral public keys" + }) + ), + "{repeated_ephemeral_result:?}" + ); + + let mut repeated_nonce = vec![0x39; 32]; + repeated_nonce.extend_from_slice(&[0x43; 24]); + repeated_nonce.extend_from_slice(&[0x3a; 32]); + repeated_nonce.extend_from_slice(&[0x43; 24]); + assert!(matches!( + rotate_group_key_with_rng(&snapshot, &group_key, &mut ScriptedRng::new(repeated_nonce),), + Err(IdentityError::DuplicateElement { + resource: "recipient key wrap nonces" + }) + )); + + let rotation = rotate_group_key_with_rng( + &snapshot, + &group_key, + &mut ScriptedRng::new((0_u8..112).collect()), + ) + .unwrap(); + assert_eq!(rotation.account_revision(), snapshot.account_revision()); + assert_eq!(rotation.account_id(), snapshot.account_id()); + assert_eq!(rotation.application_id(), snapshot.application_id()); + assert_eq!(rotation.group_id(), snapshot.group_id()); + assert_eq!( + rotation.authorizing_account_epoch(), + snapshot.authorizing_account_epoch() + ); + assert_eq!(rotation.group_key_epoch(), snapshot.group_key_epoch()); + assert_eq!( + rotation.expected_recipient_ids().collect::>(), + snapshot.expected_recipient_ids().collect::>() + ); + rotation.validate_current_revision(&state).unwrap(); + let wraps = rotation.recipient_key_wraps(); + assert_eq!( + wraps + .as_slice() + .iter() + .map(WrappedGroupKey::recipient_device_id) + .collect::>(), + snapshot.expected_recipient_ids().collect::>() + ); + assert_ne!( + wraps.as_slice()[0].header().ephemeral_public_key(), + wraps.as_slice()[1].header().ephemeral_public_key() + ); + assert_ne!( + wraps.as_slice()[0].header().nonce(), + wraps.as_slice()[1].header().nonce() + ); +} + +#[test] +fn atomic_store_revalidates_rotation_revision_and_gates_protected_writes() { + let signer = SecretKey::from_bytes(&[7; 32]); + let control_policy = ControlPolicy::new( + vec![rule(OperationKind::AuthorizeDevice)], + Extensions::default(), + ) + .unwrap(); + let recovery_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x82; 32], + Timestamp::from_unix_millis(1), + control_policy, + vec![controller(&signer)], + recovery_policy, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let account_id = genesis.account_id().unwrap(); + let first_secret = AgreementSecretKey::from_bytes([0x31; 32]); + let second_secret = AgreementSecretKey::from_bytes([0x32; 32]); + let first = authorization(&first_secret, 0, 1); + let second = authorization(&second_secret, 1, 2); + let application_id = ApplicationId::new(digest(0xa1)); + let group_id = GroupId::new(digest(0xb2)); + let store = MemoryAccountStore::new(); + let initial = futures_lite::future::block_on(store.create_account(genesis)).unwrap(); + + let first_event = authorized_event( + initial.state(), + AccountOperation::AuthorizeDevice(first.clone()), + Epoch::new(1), + 10, + &signer, + ); + let after_first = + futures_lite::future::block_on(store.commit_event(initial.revision().clone(), first_event)) + .unwrap(); + let stale_snapshot = GroupKeyDistributionSnapshot::from_post_state( + after_first.snapshot().state(), + application_id, + group_id, + GroupKeyEpoch::new(1), + vec![first.device_id()], + ) + .unwrap(); + let stale_rotation = rotate_group_key_with_rng( + &stale_snapshot, + &GroupKey::new([0x72; 32]), + &mut ScriptedRng::new(vec![0x73; 56]), + ) + .unwrap(); + + let second_event = authorized_event( + after_first.snapshot().state(), + AccountOperation::AuthorizeDevice(second.clone()), + Epoch::new(2), + 11, + &signer, + ); + let after_second = futures_lite::future::block_on( + store.commit_event(after_first.snapshot().revision().clone(), second_event), + ) + .unwrap(); + let current_snapshot = GroupKeyDistributionSnapshot::from_post_state( + after_second.snapshot().state(), + application_id, + group_id, + GroupKeyEpoch::new(2), + vec![first.device_id(), second.device_id()], + ) + .unwrap(); + let current_rotation = rotate_group_key_with_rng( + ¤t_snapshot, + &GroupKey::new([0x74; 32]), + &mut ScriptedRng::new((0_u8..112).collect()), + ) + .unwrap(); + let retry_rotation = rotate_group_key_with_rng( + ¤t_snapshot, + &GroupKey::new([0x74; 32]), + &mut ScriptedRng::new((0_u8..112).collect()), + ) + .unwrap(); + let conflicting_current_rotation = rotate_group_key_with_rng( + ¤t_snapshot, + &GroupKey::new([0x77; 32]), + &mut ScriptedRng::new((112_u8..224).collect()), + ) + .unwrap(); + assert_eq!( + futures_lite::future::block_on(store.authorize_protected_write( + after_second.snapshot().revision().clone(), + application_id, + group_id, + )), + Err(IdentityError::ProtectedWritesBlocked) + ); + + let lease_id = LeaseId::new([0x76; 16]).unwrap(); + let claim = ClaimEffects::new( + Timestamp::from_unix_millis(100), + Timestamp::from_unix_millis(200), + lease_id, + 8, + ) + .unwrap(); + let effects = futures_lite::future::block_on(store.claim_effects(account_id, claim)).unwrap(); + let stale_effect_id = effects + .iter() + .find_map(|record| match record.effect() { + ProjectionEffect::RotateGroupKeys { epoch, .. } if epoch == Epoch::new(1) => { + Some(record.id()) + } + _ => None, + }) + .unwrap(); + let current_effect_id = effects + .iter() + .find_map(|record| match record.effect() { + ProjectionEffect::RotateGroupKeys { epoch, .. } if epoch == Epoch::new(2) => { + Some(record.id()) + } + _ => None, + }) + .unwrap(); + assert!(matches!( + futures_lite::future::block_on(store.commit_group_key_rotation( + stale_effect_id, + lease_id, + stale_rotation, + Timestamp::from_unix_millis(150), + )), + Err(IdentityError::StaleRevision) + )); + let stored = futures_lite::future::block_on(store.commit_group_key_rotation( + current_effect_id, + lease_id, + current_rotation, + Timestamp::from_unix_millis(150), + )) + .unwrap(); + assert_eq!(stored.group_key_epoch(), GroupKeyEpoch::new(2)); + assert_eq!( + futures_lite::future::block_on(store.commit_group_key_rotation( + current_effect_id, + lease_id, + retry_rotation, + Timestamp::from_unix_millis(150), + )) + .unwrap(), + stored + ); + assert_eq!( + futures_lite::future::block_on(store.commit_group_key_rotation( + current_effect_id, + lease_id, + conflicting_current_rotation, + Timestamp::from_unix_millis(151), + )), + Err(IdentityError::StaleRevision) + ); + futures_lite::future::block_on(store.authorize_protected_write( + after_second.snapshot().revision().clone(), + application_id, + group_id, + )) + .unwrap(); +} diff --git a/protocols/krikos-identity/tests/merkle.rs b/protocols/krikos-identity/tests/merkle.rs new file mode 100644 index 00000000000..7789111105d --- /dev/null +++ b/protocols/krikos-identity/tests/merkle.rs @@ -0,0 +1,351 @@ +use krikos_identity::{ + CanonicalWire, Digest, HashAlgorithm, IdentityError, + limits::MAX_MERKLE_SET_LEAVES, + merkle::{ + MerkleConsistencyProof, MerkleInclusionProof, MerkleNeighbor, MerkleNonMembershipProof, + MerkleSet, MerkleSetKey, MerkleSetLeaf, empty_merkle_root, + }, +}; +use proptest::prelude::*; + +fn digest(byte: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [byte; 32]) +} + +fn key(tag: u16, byte: u8) -> MerkleSetKey { + MerkleSetKey::new(tag, digest(byte)).expect("nonzero test tag") +} + +fn leaf(tag: u16, byte: u8, value: u8) -> MerkleSetLeaf { + MerkleSetLeaf::new(key(tag, byte), digest(value)) +} + +#[test] +fn roots_are_domain_separated_deterministic_and_frozen() { + let empty = MerkleSet::new(Vec::new()).expect("empty set"); + assert_eq!(empty.root().expect("empty root"), empty_merkle_root()); + assert_eq!( + empty_merkle_root().to_string(), + "b3:ac852bf31ef19b5d18fd8df40dcb4f07a8ea8066ca4094464f431618ebf339b7" + ); + + let one = MerkleSet::new(vec![leaf(7, 1, 11)]).expect("one leaf"); + let four = MerkleSet::new(vec![ + leaf(7, 4, 14), + leaf(7, 1, 11), + leaf(7, 3, 13), + leaf(7, 2, 12), + ]) + .expect("four leaves"); + + assert_ne!( + empty.root().expect("empty root"), + one.root().expect("one root") + ); + assert_eq!( + one.root().expect("one root").to_string(), + "b3:435255485d5bb197e19d080ab391a6ae80437d4fe0b70cff6e57bcefb8b8065e" + ); + assert_eq!( + four.root().expect("four root").to_string(), + "b3:64fd4c0da68b4383eabf387c07a16c0bb155497da84406e5bedf245882ed1ef2" + ); + assert_eq!(four.entries()[0].key(), key(7, 1)); + assert_eq!(four.entries()[3].key(), key(7, 4)); +} + +#[test] +fn frozen_roots_are_independently_staged_from_the_wire_profile() { + fn raw_hash(domain: &[u8], payload: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(payload); + *hasher.finalize().as_bytes() + } + + fn digest_wire(bytes: [u8; 32]) -> Vec { + let mut wire = Vec::with_capacity(33); + wire.push(1); // BLAKE3-256 registry codepoint as a minimal Postcard varint. + wire.extend_from_slice(&bytes); + wire + } + + fn raw_leaf(id: u8, value: u8) -> [u8; 32] { + let mut payload = vec![7]; // Type tag 7 as a minimal Postcard varint. + payload.extend_from_slice(&digest_wire([id; 32])); + payload.extend_from_slice(&digest_wire([value; 32])); + raw_hash(b"KRIKOS-ID/merkle-leaf/v1", &payload) + } + + fn raw_node(left: [u8; 32], right: [u8; 32]) -> [u8; 32] { + let mut payload = digest_wire(left); + payload.extend_from_slice(&digest_wire(right)); + raw_hash(b"KRIKOS-ID/merkle-node/v1", &payload) + } + + let leaves = [ + raw_leaf(1, 11), + raw_leaf(2, 12), + raw_leaf(3, 13), + raw_leaf(4, 14), + ]; + let independently_staged = raw_node( + raw_node(leaves[0], leaves[1]), + raw_node(leaves[2], leaves[3]), + ); + let production = MerkleSet::new(vec![ + leaf(7, 4, 14), + leaf(7, 1, 11), + leaf(7, 3, 13), + leaf(7, 2, 12), + ]) + .expect("set") + .root() + .expect("root"); + assert_eq!(production.as_bytes(), &independently_staged); +} + +#[test] +fn set_rejects_duplicate_keys_and_zero_type_tags() { + assert_eq!( + MerkleSetKey::new(0, digest(1)), + Err(IdentityError::ZeroValue { + resource: "Merkle set leaf type tag" + }) + ); + assert_eq!( + MerkleSet::new(vec![leaf(1, 1, 10), leaf(1, 1, 20)]), + Err(IdentityError::DuplicateElement { + resource: "Merkle set leaf key" + }) + ); + assert!(matches!( + MerkleSet::new(vec![leaf(1, 1, 10); MAX_MERKLE_SET_LEAVES + 1]), + Err(IdentityError::LimitExceeded { + resource: "Merkle set leaves", + actual, + maximum: MAX_MERKLE_SET_LEAVES, + }) if actual == MAX_MERKLE_SET_LEAVES + 1 + )); +} + +#[test] +fn inclusion_proofs_verify_every_shape_and_reject_substitution() { + for size in 1_u8..=17 { + let entries = (0..size) + .map(|index| leaf(1, index, index.wrapping_add(80))) + .collect(); + let set = MerkleSet::new(entries).expect("unique set"); + let root = set.root().expect("root"); + + for entry in set.entries() { + let proof = set + .inclusion_proof(entry.key()) + .expect("included leaf proof"); + proof.verify(entry, root).expect("valid proof"); + + assert_eq!( + proof.verify(&leaf(1, entry.key().id().as_bytes()[0], 250), root), + Err(IdentityError::InvalidProof) + ); + assert_eq!( + proof.verify(entry, digest(250)), + Err(IdentityError::InvalidProof) + ); + } + } +} + +#[test] +fn inclusion_proof_shape_is_exact_and_wire_decode_is_bounded() { + assert_eq!( + MerkleInclusionProof::new(1, 1, Vec::new()), + Err(IdentityError::InvalidProof) + ); + assert_eq!( + MerkleInclusionProof::new(0, 1, vec![digest(1)]), + Err(IdentityError::InvalidProof) + ); + assert!(matches!( + MerkleInclusionProof::new(0, u64::MAX, vec![digest(1); 65]), + Err(IdentityError::LimitExceeded { + resource: "Merkle audit path", + actual: 65, + maximum: 64 + }) + )); + + let set = MerkleSet::new((0..9).map(|index| leaf(2, index, index + 1)).collect()) + .expect("unique set"); + let proof = set.inclusion_proof(key(2, 7)).expect("proof"); + let bytes = proof.to_canonical_bytes().expect("encode proof"); + assert_eq!( + MerkleInclusionProof::from_canonical_bytes(&bytes).expect("decode proof"), + proof + ); +} + +#[test] +fn non_membership_proves_empty_boundaries_and_adjacency() { + let empty = MerkleSet::new(Vec::new()).expect("empty set"); + let empty_proof = empty.non_membership_proof(key(1, 9)).expect("empty proof"); + empty_proof + .verify(key(1, 9), empty.root().expect("root")) + .expect("empty non-membership"); + + let set = + MerkleSet::new(vec![leaf(1, 20, 1), leaf(1, 40, 2), leaf(1, 60, 3)]).expect("unique set"); + let root = set.root().expect("root"); + for missing in [10, 30, 50, 70] { + let query = key(1, missing); + let proof = set.non_membership_proof(query).expect("missing proof"); + proof.verify(query, root).expect("valid non-membership"); + + let bytes = proof.to_canonical_bytes().expect("encode proof"); + let decoded = MerkleNonMembershipProof::from_canonical_bytes(&bytes).expect("decode proof"); + assert_eq!(decoded, proof); + } + + assert_eq!( + set.non_membership_proof(key(1, 40)), + Err(IdentityError::InvalidRelationship { + resource: "Merkle non-membership query is present" + }) + ); +} + +#[test] +fn non_membership_rejects_non_adjacent_or_wrong_boundary_neighbors() { + let set = + MerkleSet::new(vec![leaf(1, 20, 1), leaf(1, 40, 2), leaf(1, 60, 3)]).expect("unique set"); + let root = set.root().expect("root"); + + let first = MerkleNeighbor::new( + set.entries()[0], + set.inclusion_proof(key(1, 20)).expect("proof"), + ) + .expect("neighbor"); + let last = MerkleNeighbor::new( + set.entries()[2], + set.inclusion_proof(key(1, 60)).expect("proof"), + ) + .expect("neighbor"); + assert_eq!( + MerkleNonMembershipProof::new(3, Some(first.clone()), Some(last.clone())), + Err(IdentityError::InvalidProof) + ); + + let wrong_low_boundary = + MerkleNonMembershipProof::new(3, None, Some(last)).expect_err("not first leaf"); + assert_eq!(wrong_low_boundary, IdentityError::InvalidProof); + + let wrong_high_boundary = + MerkleNonMembershipProof::new(3, Some(first), None).expect_err("not last leaf"); + assert_eq!(wrong_high_boundary, IdentityError::InvalidProof); + + let valid = set.non_membership_proof(key(1, 30)).expect("proof"); + assert_eq!( + valid.verify(key(1, 30), digest(99)), + Err(IdentityError::InvalidProof) + ); + valid.verify(key(1, 30), root).expect("valid proof"); +} + +#[test] +fn consistency_proofs_cover_every_prefix_and_tree_shape() { + for new_size in 0_u8..=65 { + let entries: Vec<_> = (0..new_size) + .map(|index| leaf(11, index, index.wrapping_add(90))) + .collect(); + let new_set = MerkleSet::new(entries).expect("new set"); + let new_root = new_set.root().expect("new root"); + + for old_size in 0..=new_size { + let old_set = MerkleSet::new(new_set.entries()[..usize::from(old_size)].to_vec()) + .expect("old prefix"); + let old_root = old_set.root().expect("old root"); + let proof = new_set + .consistency_proof(u64::from(old_size)) + .expect("consistency proof"); + proof + .verify(old_root, new_root) + .expect("prefix is consistent"); + + let bytes = proof.to_canonical_bytes().expect("encode proof"); + assert_eq!( + MerkleConsistencyProof::from_canonical_bytes(&bytes).expect("decode proof"), + proof + ); + + if old_size != 0 { + assert_eq!( + proof.verify(digest(0xfe), new_root), + Err(IdentityError::InvalidProof) + ); + } + if old_size == new_size || old_size != 0 { + assert_eq!( + proof.verify(old_root, digest(0xfd)), + Err(IdentityError::InvalidProof) + ); + } + } + } +} + +#[test] +fn consistency_proof_shape_and_tampering_fail_closed() { + assert_eq!( + MerkleConsistencyProof::new(2, 1, Vec::new()), + Err(IdentityError::InvalidProof) + ); + assert!(matches!( + MerkleConsistencyProof::new(1, u64::MAX, vec![digest(1); 65]), + Err(IdentityError::LimitExceeded { + resource: "Merkle consistency path", + actual: 65, + maximum: 64, + }) + )); + + let set = + MerkleSet::new((0..13).map(|index| leaf(12, index, index + 1)).collect()).expect("set"); + assert_eq!(set.consistency_proof(14), Err(IdentityError::InvalidProof)); + let proof = set.consistency_proof(7).expect("proof"); + let mut tampered_path = proof.audit_path().to_vec(); + tampered_path[0] = digest(0xfc); + let tampered = MerkleConsistencyProof::new(7, 13, tampered_path).expect("same shape"); + let old = MerkleSet::new(set.entries()[..7].to_vec()).expect("old set"); + assert_eq!( + tampered.verify(old.root().expect("old root"), set.root().expect("new root")), + Err(IdentityError::InvalidProof) + ); +} + +proptest! { + #[test] + fn arbitrary_unique_sets_prove_all_members_and_gaps(mut ids in prop::collection::vec(any::(), 0..32)) { + ids.sort_unstable(); + ids.dedup(); + let set = MerkleSet::new( + ids.iter().map(|id| leaf(9, *id, id.wrapping_add(1))).collect() + ).expect("unique set"); + let root = set.root().expect("root"); + + for entry in set.entries() { + set.inclusion_proof(entry.key()) + .expect("member proof") + .verify(entry, root) + .expect("member verifies"); + } + + if let Some(missing) = (0_u8..=u8::MAX).find(|candidate| !ids.contains(candidate)) { + let query = key(9, missing); + set.non_membership_proof(query) + .expect("non-member proof") + .verify(query, root) + .expect("non-member verifies"); + } + } +} diff --git a/protocols/krikos-identity/tests/migration_schema.rs b/protocols/krikos-identity/tests/migration_schema.rs new file mode 100644 index 00000000000..bfc64a17fa8 --- /dev/null +++ b/protocols/krikos-identity/tests/migration_schema.rs @@ -0,0 +1,579 @@ +use krikos_identity::{ + AccountId, ActivateCryptoMigration, AlgorithmPublicKey, AlgorithmSignature, + BeginCryptoMigration, CanonicalWire, ControllerId, ControllerKeyBinding, + ControllerKeyBindingProof, ControllerKeyBindingProofSet, ControllerKeyId, CryptoMigrationBody, + CryptoSuiteDescriptor, CryptoSuiteId, Digest, EventId, Extension, Extensions, HashAlgorithm, + IdentityError, ProtocolMajor, ProtocolUpgrade, ProtocolVersion, RetireAccount, + RetireCryptoSuite, RetireCryptoSuiteMode, RevocationReasonCode, UpgradeCompatibility, + limits::{MAX_ALGORITHM_PUBLIC_KEY_BYTES, MAX_ALGORITHM_SIGNATURE_BYTES, MAX_CONTROLLERS}, +}; + +fn digest_id(byte: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [byte; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn future_suite(signature_code: u16) -> CryptoSuiteDescriptor { + CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 2, + 1, + signature_code, + 1, + 1, + 1, + Extensions::default(), + ) + .unwrap() +} + +fn binding(controller_byte: u8, key_byte: u8) -> ControllerKeyBinding { + ControllerKeyBinding::try_new( + digest_id::(controller_byte), + digest_id::(controller_byte.wrapping_add(0x40)), + AlgorithmPublicKey::new(2, vec![key_byte]).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +fn migration(bindings: Vec) -> CryptoMigrationBody { + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + digest_id::(0x11), + digest_id::(0x22), + future_suite(2), + bindings, + None, + [0x77; 32], + Extensions::default(), + ) + .unwrap() +} + +fn proof( + migration_id: krikos_identity::CryptoMigrationId, + controller_id: ControllerId, +) -> ControllerKeyBindingProof { + ControllerKeyBindingProof::try_new( + migration_id, + controller_id, + AlgorithmSignature::new(1, vec![0x81; 64]).unwrap(), + AlgorithmSignature::new(2, vec![0x82, 0x83]).unwrap(), + ) + .unwrap() +} + +#[test] +fn crypto_suite_descriptor_and_id_vector_is_frozen() { + let suite = future_suite(2); + assert_eq!( + suite.to_canonical_bytes().unwrap(), + [1, 2, 1, 2, 1, 1, 1, 0] + ); + assert_eq!( + suite.crypto_suite_id().unwrap().to_string(), + "b3:00015fc3f0269d59b8f2bfdfe6a5ae497e3e62b04771f2f740b631077f067faa" + ); + assert_eq!( + CryptoSuiteDescriptor::from_canonical_bytes(&suite.to_canonical_bytes().unwrap()).unwrap(), + suite + ); +} + +#[test] +fn begin_activate_abort_and_retire_vectors_round_trip() { + let migration = migration(vec![binding(2, 2), binding(1, 1)]); + assert_eq!( + migration.bindings()[0].controller_id(), + digest_id::(1) + ); + assert_eq!( + migration.bindings()[1].controller_id(), + digest_id::(2) + ); + + let migration_id = migration.crypto_migration_id().unwrap(); + assert_eq!( + hex::encode(migration.to_canonical_bytes().unwrap()), + "01011111111111111111111111111111111111111111111111111111111111111111012222222222222222222222222222222222222222222222222222222222222222010201020101010002010101010101010101010101010101010101010101010101010101010101010101014141414141414141414141414141414141414141414141414141414141414141020101000102020202020202020202020202020202020202020202020202020202020202020142424242424242424242424242424242424242424242424242424242424242420201020000777777777777777777777777777777777777777777777777777777777777777700" + ); + assert_eq!( + migration_id.to_string(), + "b3:4f665be79b9f4132828bbacc0eee43e29724c331824b46af38894f71b3ca55bc" + ); + let proofs = ControllerKeyBindingProofSet::try_new(vec![ + proof(migration_id, digest_id::(2)), + proof(migration_id, digest_id::(1)), + ]) + .unwrap(); + let begin = BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + proofs, + Extensions::default(), + ) + .unwrap(); + let begin_bytes = begin.to_canonical_bytes().unwrap(); + assert_eq!( + hex::encode(&begin_bytes), + "010101111111111111111111111111111111111111111111111111111111111111111101222222222222222222222222222222222222222222222222222222222222222201020102010101000201010101010101010101010101010101010101010101010101010101010101010101414141414141414141414141414141414141414141414141414141414141414102010100010202020202020202020202020202020202020202020202020202020202020202014242424242424242424242424242424242424242424242424242424242424242020102000077777777777777777777777777777777777777777777777777777777777777770002014f665be79b9f4132828bbacc0eee43e29724c331824b46af38894f71b3ca55bc01010101010101010101010101010101010101010101010101010101010101010101408181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818102028283014f665be79b9f4132828bbacc0eee43e29724c331824b46af38894f71b3ca55bc0102020202020202020202020202020202020202020202020202020202020202020140818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181810202828300" + ); + assert_eq!( + BeginCryptoMigration::from_canonical_bytes(&begin_bytes).unwrap(), + begin + ); + + let begin_event_id = digest_id::(0x51); + let activate = ActivateCryptoMigration::try_new( + ProtocolVersion::V1, + migration_id, + begin_event_id, + Extensions::default(), + ) + .unwrap(); + let activate_bytes = activate.to_canonical_bytes().unwrap(); + let mut expected_activate = vec![1]; + expected_activate.extend_from_slice(&migration_id.to_canonical_bytes().unwrap()); + expected_activate.extend_from_slice(&begin_event_id.to_canonical_bytes().unwrap()); + expected_activate.push(0); + assert_eq!(activate_bytes, expected_activate); + + let abort = RetireCryptoSuite::try_new( + ProtocolVersion::V1, + migration_id, + RetireCryptoSuiteMode::AbortCandidate, + begin_event_id, + None, + Extensions::default(), + ) + .unwrap(); + let mut expected_abort = vec![1]; + expected_abort.extend_from_slice(&migration_id.to_canonical_bytes().unwrap()); + expected_abort.push(1); + expected_abort.extend_from_slice(&begin_event_id.to_canonical_bytes().unwrap()); + expected_abort.extend_from_slice(&[0, 0]); + assert_eq!(abort.to_canonical_bytes().unwrap(), expected_abort); + + let activate_event_id = digest_id::(0x52); + let successor = digest_id::(0x53); + let retire = RetireCryptoSuite::try_new( + ProtocolVersion::V1, + migration_id, + RetireCryptoSuiteMode::RetirePrevious, + activate_event_id, + Some(successor), + Extensions::default(), + ) + .unwrap(); + let retire_bytes = retire.to_canonical_bytes().unwrap(); + assert_eq!(retire_bytes[34], 2); + assert_eq!( + RetireCryptoSuite::from_canonical_bytes(&retire_bytes).unwrap(), + retire + ); +} + +#[test] +fn migration_requires_distinct_suites_nonzero_nonce_and_signature_only_in_place_change() { + let suite = future_suite(2); + let suite_id = suite.crypto_suite_id().unwrap(); + let account_id = digest_id::(1); + let one_binding = vec![binding(1, 1)]; + + assert!(matches!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + account_id, + suite_id, + suite.clone(), + one_binding.clone(), + None, + [1; 32], + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + assert!(matches!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + account_id, + digest_id::(3), + suite, + one_binding.clone(), + None, + [0; 32], + Extensions::default(), + ), + Err(IdentityError::ZeroValue { .. }) + )); + + let digest_break_suite = CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 3, + 2, + 2, + 1, + 1, + 1, + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + account_id, + digest_id::(3), + digest_break_suite.clone(), + one_binding.clone(), + None, + [1; 32], + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + assert!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + account_id, + digest_id::(3), + digest_break_suite, + one_binding, + Some(digest_id::(4)), + [1; 32], + Extensions::default(), + ) + .is_ok() + ); +} + +#[test] +fn begin_requires_a_complete_unique_cross_binding_proof_set() { + let migration = migration(vec![binding(1, 1), binding(2, 2)]); + let migration_id = migration.crypto_migration_id().unwrap(); + let incomplete = ControllerKeyBindingProofSet::try_new(vec![proof( + migration_id, + digest_id::(1), + )]) + .unwrap(); + assert!(matches!( + BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration.clone(), + incomplete, + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let wrong_id = digest_id(0x99); + let mismatched = ControllerKeyBindingProofSet::try_new(vec![ + proof(wrong_id, digest_id::(1)), + proof(wrong_id, digest_id::(2)), + ]) + .unwrap(); + assert!(matches!( + BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + mismatched, + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn migration_collections_and_algorithm_material_are_bounded_and_unique() { + let duplicate = vec![binding(1, 1), binding(1, 2)]; + assert!(matches!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + digest_id::(1), + digest_id::(2), + future_suite(2), + duplicate, + None, + [1; 32], + Extensions::default(), + ), + Err(IdentityError::DuplicateElement { .. }) + )); + + let too_many = (0..=MAX_CONTROLLERS) + .map(|index| { + binding( + u8::try_from(index + 1).unwrap(), + u8::try_from(index + 1).unwrap(), + ) + }) + .collect(); + assert!(matches!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + digest_id::(1), + digest_id::(2), + future_suite(2), + too_many, + None, + [1; 32], + Extensions::default(), + ), + Err(IdentityError::LimitExceeded { + maximum: MAX_CONTROLLERS, + .. + }) + )); + + assert!(AlgorithmPublicKey::new(2, vec![1; MAX_ALGORITHM_PUBLIC_KEY_BYTES]).is_ok()); + assert!(matches!( + AlgorithmPublicKey::new(2, vec![1; MAX_ALGORITHM_PUBLIC_KEY_BYTES + 1]), + Err(IdentityError::LimitExceeded { .. }) + )); + assert!(AlgorithmSignature::new(2, vec![1; MAX_ALGORITHM_SIGNATURE_BYTES]).is_ok()); + assert!(matches!( + AlgorithmSignature::new(2, vec![1; MAX_ALGORITHM_SIGNATURE_BYTES + 1]), + Err(IdentityError::LimitExceeded { .. }) + )); +} + +#[test] +fn canonical_decode_rejects_unsorted_and_duplicate_controller_bindings() { + let canonical = migration(vec![binding(1, 1), binding(2, 2)]) + .to_canonical_bytes() + .unwrap(); + + // The frozen v1 prefix is version + two digest IDs + suite + collection length. + const FIRST_BINDING: usize = 76; + const BINDING_LENGTH: usize = 70; + let second_binding = FIRST_BINDING + BINDING_LENGTH; + let after_bindings = second_binding + BINDING_LENGTH; + + let mut unsorted = canonical.clone(); + let first = unsorted[FIRST_BINDING..second_binding].to_vec(); + let second = unsorted[second_binding..after_bindings].to_vec(); + unsorted[FIRST_BINDING..second_binding].copy_from_slice(&second); + unsorted[second_binding..after_bindings].copy_from_slice(&first); + assert!(CryptoMigrationBody::from_canonical_bytes(&unsorted).is_err()); + + let mut duplicate = canonical; + let first = duplicate[FIRST_BINDING..second_binding].to_vec(); + duplicate[second_binding..after_bindings].copy_from_slice(&first); + assert!(CryptoMigrationBody::from_canonical_bytes(&duplicate).is_err()); +} + +#[test] +fn proof_sets_reject_duplicates_over_limit_and_wrong_candidate_algorithms() { + let migration = migration(vec![binding(1, 1)]); + let migration_id = migration.crypto_migration_id().unwrap(); + let duplicate = vec![ + proof(migration_id, digest_id::(1)), + proof(migration_id, digest_id::(1)), + ]; + assert!(matches!( + ControllerKeyBindingProofSet::try_new(duplicate), + Err(IdentityError::DuplicateElement { .. }) + )); + + let too_many = (0..=MAX_CONTROLLERS) + .map(|index| { + proof( + migration_id, + digest_id::(u8::try_from(index + 1).unwrap()), + ) + }) + .collect(); + assert!(matches!( + ControllerKeyBindingProofSet::try_new(too_many), + Err(IdentityError::LimitExceeded { + maximum: MAX_CONTROLLERS, + .. + }) + )); + + let wrong_algorithm = ControllerKeyBindingProofSet::try_new(vec![ + ControllerKeyBindingProof::try_new( + migration_id, + digest_id::(1), + AlgorithmSignature::new(1, vec![0x81; 64]).unwrap(), + AlgorithmSignature::new(3, vec![0x82]).unwrap(), + ) + .unwrap(), + ]) + .unwrap(); + assert!(matches!( + BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + wrong_algorithm, + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn raw_codepoints_and_cross_field_relationships_fail_closed() { + assert!(matches!( + CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 0, + 1, + 2, + 1, + 1, + 1, + Extensions::default(), + ), + Err(IdentityError::ZeroValue { .. }) + )); + + let wrong_key_algorithm = ControllerKeyBinding::try_new( + digest_id::(1), + digest_id::(2), + AlgorithmPublicKey::new(3, vec![1]).unwrap(), + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + digest_id::(1), + digest_id::(2), + future_suite(2), + vec![wrong_key_algorithm], + None, + [1; 32], + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let account_id = digest_id::(4); + assert!(matches!( + CryptoMigrationBody::try_new( + ProtocolVersion::V1, + account_id, + digest_id::(2), + future_suite(2), + vec![binding(1, 1)], + Some(account_id), + [1; 32], + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + // Version, no successor, present zero reason, then empty extensions. + assert!(RetireAccount::from_canonical_bytes(&[1, 0, 1, 0, 0]).is_err()); +} + +#[test] +fn unknown_noncritical_extensions_are_signed_and_critical_extensions_fail_closed() { + let noncritical = + Extensions::new(vec![Extension::new(9, false, vec![0xaa, 0xbb]).unwrap()]).unwrap(); + let suite = + CryptoSuiteDescriptor::try_new(ProtocolVersion::V1, 2, 1, 2, 1, 1, 1, noncritical).unwrap(); + assert_eq!( + suite.to_canonical_bytes().unwrap(), + [1, 2, 1, 2, 1, 1, 1, 1, 9, 0, 2, 0xaa, 0xbb] + ); + assert_eq!(suite.extensions().as_slice()[0].code(), 9); + + let critical = Extensions::new(vec![Extension::new(9, true, vec![]).unwrap()]).unwrap(); + assert!(matches!( + CryptoSuiteDescriptor::try_new(ProtocolVersion::V1, 2, 1, 2, 1, 1, 1, critical,), + Err(IdentityError::UnknownCriticalExtension { code: 9 }) + )); + assert!( + CryptoSuiteDescriptor::from_canonical_bytes(&[1, 2, 1, 2, 1, 1, 1, 1, 9, 1, 0]).is_err() + ); +} + +#[test] +fn upgrade_and_terminal_retirement_vectors_are_frozen() { + assert_eq!( + RetireCryptoSuiteMode::AbortCandidate + .to_canonical_bytes() + .unwrap(), + [1] + ); + assert_eq!( + RetireCryptoSuiteMode::RetirePrevious + .to_canonical_bytes() + .unwrap(), + [2] + ); + assert_eq!( + UpgradeCompatibility::OldClientsReadOnly + .to_canonical_bytes() + .unwrap(), + [1] + ); + assert!(RetireCryptoSuiteMode::from_canonical_bytes(&[3]).is_err()); + assert!(UpgradeCompatibility::from_canonical_bytes(&[2]).is_err()); + + let from = ProtocolMajor::new(1).unwrap(); + let to = ProtocolMajor::new(2).unwrap(); + let spec_digest = Digest::new(HashAlgorithm::Blake3_256, [0x91; 32]); + let successor = digest_id::(0x92); + let upgrade = ProtocolUpgrade::try_new( + ProtocolVersion::V1, + from, + to, + spec_digest, + UpgradeCompatibility::OldClientsReadOnly, + Some(successor), + Extensions::default(), + ) + .unwrap(); + let mut expected_upgrade = vec![1, 1, 2, 1]; + expected_upgrade.extend_from_slice(&[0x91; 32]); + expected_upgrade.extend_from_slice(&[1, 1, 1]); + expected_upgrade.extend_from_slice(&[0x92; 32]); + expected_upgrade.push(0); + assert_eq!(upgrade.to_canonical_bytes().unwrap(), expected_upgrade); + assert!( + ProtocolUpgrade::try_new( + ProtocolVersion::V1, + to, + from, + spec_digest, + UpgradeCompatibility::OldClientsReadOnly, + None, + Extensions::default(), + ) + .is_err() + ); + + let retired = RetireAccount::try_new( + ProtocolVersion::V1, + Some(successor), + Some(RevocationReasonCode::new(7).unwrap()), + Extensions::default(), + ) + .unwrap(); + let mut expected_retired = vec![1, 1, 1]; + expected_retired.extend_from_slice(&[0x92; 32]); + expected_retired.extend_from_slice(&[1, 7, 0]); + assert_eq!(retired.to_canonical_bytes().unwrap(), expected_retired); + assert_eq!( + RetireAccount::from_canonical_bytes(&expected_retired).unwrap(), + retired + ); +} + +#[test] +fn abort_mode_cannot_publish_a_successor() { + assert!(matches!( + RetireCryptoSuite::try_new( + ProtocolVersion::V1, + digest_id(1), + RetireCryptoSuiteMode::AbortCandidate, + digest_id(2), + Some(digest_id(3)), + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} diff --git a/protocols/krikos-identity/tests/names.rs b/protocols/krikos-identity/tests/names.rs new file mode 100644 index 00000000000..8c2a89ef6b8 --- /dev/null +++ b/protocols/krikos-identity/tests/names.rs @@ -0,0 +1,329 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, AlgorithmSignature, CanonicalWire, CheckpointId, Digest, Extensions, HashAlgorithm, + IdentityError, NameAuthorityContext, NameClaimBody, NameResolver, NormalizedName, + SignedNameClaim, SigningPublicKey, Timestamp, TofuDecision, TofuObservation, + evaluate_name_tofu, resolve_name_candidates, verify_name_candidates, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn signed_claim( + name: &str, + secret: &SecretKey, + account_id: AccountId, + checkpoint_id: CheckpointId, + issued_at: u64, + expires_at: Option, +) -> SignedNameClaim { + let body = NameClaimBody::try_new( + NormalizedName::try_new(name).unwrap(), + account_id, + checkpoint_id, + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Timestamp::from_unix_millis(issued_at), + expires_at.map(Timestamp::from_unix_millis), + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&body.signing_bytes().unwrap()); + SignedNameClaim::try_new( + body, + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + ) + .unwrap() +} + +fn context_from_body(body: &NameClaimBody, authority_time: Timestamp) -> NameAuthorityContext { + NameAuthorityContext::try_new( + body.name().clone(), + body.subject_account_id(), + body.subject_checkpoint_id(), + body.subject_signing_key(), + authority_time, + ) + .unwrap() +} + +#[test] +fn normalized_name_and_signed_claim_bind_exact_authority_and_time() { + let name = NormalizedName::try_new("Alice.Example").unwrap(); + assert_eq!(name.as_str(), "alice.example"); + assert!(NormalizedName::try_new("alice..example").is_err()); + assert!(NormalizedName::try_new("álîce.example").is_err()); + assert!(NormalizedName::try_new(&format!("{}.example", "a".repeat(64))).is_err()); + + let secret = SecretKey::from_bytes(&[0x11; 32]); + let account_id = typed_id::(0x12); + let checkpoint_id = typed_id::(0x13); + let claim = signed_claim( + name.as_str(), + &secret, + account_id, + checkpoint_id, + 10, + Some(20), + ); + let context = NameAuthorityContext::try_new( + name.clone(), + account_id, + checkpoint_id, + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + let candidates = krikos_identity::NameCandidateSet::try_new(vec![claim.clone()]).unwrap(); + let verified = verify_name_candidates(&candidates, &[context]).unwrap(); + assert_eq!(verified.as_slice().len(), 1); + assert_eq!(verified.as_slice()[0].name(), &name); + + let wrong_checkpoint = NameAuthorityContext::try_new( + name.clone(), + account_id, + typed_id::(0x14), + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert!( + verify_name_candidates(&candidates, &[wrong_checkpoint]) + .unwrap() + .as_slice() + .is_empty() + ); + + let replacement = SecretKey::from_bytes(&[0x15; 32]); + let wrong_key = NameAuthorityContext::try_new( + name.clone(), + account_id, + checkpoint_id, + SigningPublicKey::ed25519(*replacement.public().as_bytes()).unwrap(), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert!( + verify_name_candidates(&candidates, &[wrong_key]) + .unwrap() + .as_slice() + .is_empty() + ); + + let expired = NameAuthorityContext::try_new( + name, + account_id, + checkpoint_id, + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Timestamp::from_unix_millis(20), + ) + .unwrap(); + assert!( + verify_name_candidates(&candidates, &[expired]) + .unwrap() + .as_slice() + .is_empty() + ); + + assert_eq!( + SignedNameClaim::try_new( + claim.body().clone(), + AlgorithmSignature::new(1, vec![0x55; 64]).unwrap(), + ), + Err(IdentityError::InvalidSignature) + ); +} + +struct StaticResolver { + candidates: Vec, +} + +impl NameResolver for StaticResolver { + fn resolve( + &self, + _name: &NormalizedName, + _maximum_candidates: usize, + ) -> Result, IdentityError> { + Ok(self.candidates.clone()) + } +} + +#[test] +fn malicious_resolver_is_bounded_and_candidates_are_cryptographically_filtered() { + let secret = SecretKey::from_bytes(&[0x21; 32]); + let other = SecretKey::from_bytes(&[0x22; 32]); + let account_id = typed_id::(0x23); + let checkpoint_id = typed_id::(0x24); + let valid = signed_claim( + "alice.example", + &secret, + account_id, + checkpoint_id, + 10, + Some(20), + ); + let wrong_name = signed_claim( + "mallory.example", + &secret, + account_id, + checkpoint_id, + 10, + Some(20), + ); + let wrong_authority = signed_claim( + "alice.example", + &other, + typed_id::(0x25), + typed_id::(0x26), + 10, + Some(20), + ); + let resolver = StaticResolver { + candidates: vec![valid, wrong_name, wrong_authority], + }; + let name = NormalizedName::try_new("alice.example").unwrap(); + let candidates = resolve_name_candidates(&resolver, &name).unwrap(); + let context = NameAuthorityContext::try_new( + name, + account_id, + checkpoint_id, + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + let verified = verify_name_candidates(&candidates, &[context]).unwrap(); + assert_eq!(verified.as_slice().len(), 1); + assert_eq!(verified.as_slice()[0].account_id(), account_id); + + let oversized = StaticResolver { + candidates: vec![ + resolver.candidates[0].clone(); + krikos_identity::limits::MAX_NAME_CLAIMS + 1 + ], + }; + assert!(matches!( + resolve_name_candidates( + &oversized, + &NormalizedName::try_new("alice.example").unwrap() + ), + Err(IdentityError::LimitExceeded { .. }) + )); +} + +#[test] +fn tofu_first_use_unchanged_and_key_change_are_pure_decisions() { + let first_secret = SecretKey::from_bytes(&[0x31; 32]); + let replacement_secret = SecretKey::from_bytes(&[0x32; 32]); + let account_id = typed_id::(0x33); + let first_claim = signed_claim( + "alice.example", + &first_secret, + account_id, + typed_id::(0x34), + 10, + Some(40), + ); + let first_context = context_from_body(first_claim.body(), Timestamp::from_unix_millis(20)); + let first_candidates = krikos_identity::NameCandidateSet::try_new(vec![first_claim]).unwrap(); + let first = verify_name_candidates(&first_candidates, &[first_context]) + .unwrap() + .as_slice()[0] + .clone(); + let first_decision = evaluate_name_tofu(None, &first).unwrap(); + let TofuDecision::FirstUse { observation } = first_decision else { + panic!("expected explicit first-use decision") + }; + let pinned = observation.clone(); + assert!(matches!( + evaluate_name_tofu(Some(&pinned), &first).unwrap(), + TofuDecision::Unchanged { .. } + )); + + let checkpoint_changed_claim = signed_claim( + "alice.example", + &first_secret, + account_id, + typed_id::(0x35), + 21, + Some(40), + ); + let checkpoint_changed_context = context_from_body( + checkpoint_changed_claim.body(), + Timestamp::from_unix_millis(22), + ); + let checkpoint_changed_candidates = + krikos_identity::NameCandidateSet::try_new(vec![checkpoint_changed_claim]).unwrap(); + let checkpoint_changed = verify_name_candidates( + &checkpoint_changed_candidates, + &[checkpoint_changed_context], + ) + .unwrap() + .as_slice()[0] + .clone(); + let checkpoint_decision = evaluate_name_tofu(Some(&pinned), &checkpoint_changed).unwrap(); + let TofuDecision::CheckpointChanged { previous, current } = checkpoint_decision else { + panic!("expected explicit checkpoint-change decision") + }; + assert_eq!(previous, pinned); + assert_ne!(previous.checkpoint_id(), current.checkpoint_id()); + assert!(matches!( + evaluate_name_tofu(Some(¤t), &first).unwrap(), + TofuDecision::CheckpointChanged { .. } + )); + + let changed_claim = signed_claim( + "alice.example", + &replacement_secret, + account_id, + typed_id::(0x36), + 23, + Some(40), + ); + let changed_context = context_from_body(changed_claim.body(), Timestamp::from_unix_millis(24)); + let changed_candidates = + krikos_identity::NameCandidateSet::try_new(vec![changed_claim]).unwrap(); + let changed = verify_name_candidates(&changed_candidates, &[changed_context]) + .unwrap() + .as_slice()[0] + .clone(); + let decision = evaluate_name_tofu(Some(&pinned), &changed).unwrap(); + let TofuDecision::KeyChanged { previous, current } = decision else { + panic!("expected explicit key-change decision") + }; + assert_eq!(previous, pinned); + assert_ne!(previous.signing_key(), current.signing_key()); + assert_eq!(pinned, observation); +} + +#[test] +fn signed_name_claim_vector_is_canonical() { + let secret = SecretKey::from_bytes(&[0x41; 32]); + let claim = signed_claim( + "alice.example", + &secret, + typed_id::(0x42), + typed_id::(0x43), + 10, + Some(20), + ); + let encoded = claim.to_canonical_bytes().unwrap(); + assert_eq!( + SignedNameClaim::from_canonical_bytes(&encoded).unwrap(), + claim + ); + assert_eq!( + blake3::hash(&encoded).as_bytes(), + &[ + 0x38, 0x86, 0x8c, 0x61, 0xa6, 0xb0, 0xaa, 0xfa, 0x86, 0xbf, 0x2b, 0xb9, 0x7f, 0xb5, + 0x7b, 0x7e, 0xa7, 0xcb, 0xa5, 0x67, 0x5e, 0xfc, 0x06, 0x70, 0xc6, 0x08, 0x3b, 0x5b, + 0x2e, 0x3b, 0x80, 0xa2, + ] + ); +} + +#[test] +fn tofu_observation_is_an_explicit_value_not_a_mutable_store_handle() { + fn assert_value_traits() {} + assert_value_traits::(); +} diff --git a/protocols/krikos-identity/tests/net_contracts.rs b/protocols/krikos-identity/tests/net_contracts.rs new file mode 100644 index 00000000000..ec86187b6fa --- /dev/null +++ b/protocols/krikos-identity/tests/net_contracts.rs @@ -0,0 +1,1561 @@ +#![cfg(feature = "net")] + +use std::{ + convert::Infallible, + future::pending, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use krikos::{ + Endpoint, RelayMode, + endpoint::{Connection, presets}, + protocol::{AcceptError, ProtocolHandler, Router}, +}; +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountId, AccountOperation, AccountState, AccountStore, AdmissionEvidence, + AgreementSecretKey, AlgorithmSignature, AuthorizedEvent, CanonicalWire, CheckpointId, + ControllerApprovalBody, ControllerApprovals, ControllerClass, ControllerDescriptor, + ControllerKeyId, ControllerScope, CryptoSuiteDescriptor, CursorKey, DelayEvidence, + DeviceAuthorizationProposal, DeviceDescriptor, DeviceId, Digest, EndpointPublicKey, EventBody, + EventPredecessors, Extensions, FreshnessEvidence, HashAlgorithm, IdentityError, KeyedSignature, + MemoryAccountStore, PairingTicket, PairingTicketRequest, ProjectedDeviceLifecycle, + RecoveryProposal, SignedCheckpoint, SignedControllerApproval, SignedProviderHead, + SigningPublicKey, StoreFuture, SyncRequest, SyncResponse, SyncSessionBudget, Timestamp, + net::{ + AuthorizedCheckpointRequest, AuthorizedProposalRequest, AuthorizedSyncRequest, + EndpointAuthorizationRequest, IdentityProtocolAck, IdentityProtocolHandlers, + IdentityProtocolKind, IdentityProtocolReply, IdentityProtocolService, + IdentityServiceOutcome, IdentityTaskSupervisor, read_bounded_frame, write_bounded_frame, + }, + transport::{CheckpointDeviceEndpoint, VerifiedCheckpointView, authorize_endpoint_stream}, +}; +use rand_core::{TryCryptoRng, TryRng}; +use tokio::{ + io::{AsyncWriteExt, duplex, sink}, + sync::{Notify, Semaphore, oneshot}, + task::JoinSet, +}; + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn endpoint(fill: u8) -> EndpointPublicKey { + let secret = SecretKey::from_bytes(&[fill; 32]); + EndpointPublicKey::new(SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap()) +} + +fn controller_descriptor(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + krikos_identity::ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn followup_event(state: &AccountState) -> AuthorizedEvent { + let signer = SecretKey::from_bytes(&[0x11; 32]); + let operation = + AccountOperation::AddController(controller_descriptor(&SecretKey::from_bytes(&[0x15; 32]))); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.expected_epoch_for(&operation).unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(3), + [0x16; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x22); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + evidence.event_id_for_body(&body).unwrap(), + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +#[derive(Debug, Default)] +struct AcceptPairingService { + pairing_calls: AtomicUsize, + sync_calls: AtomicUsize, + proposal_calls: AtomicUsize, + checkpoint_calls: AtomicUsize, + gossip_calls: AtomicUsize, + recovery_calls: AtomicUsize, +} + +impl IdentityProtocolService for AcceptPairingService { + fn pairing( + &self, + _transport: krikos_identity::AuthenticatedTransportBinding, + _ticket: PairingTicket, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.pairing_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(IdentityServiceOutcome::Accepted) }) + } + + fn sync( + &self, + _authorized: krikos_identity::transport::AuthorizedEndpointStream, + _request: SyncRequest, + _response: SyncResponse, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.sync_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(IdentityServiceOutcome::Accepted) }) + } + + fn proposal( + &self, + _authorized: krikos_identity::transport::AuthorizedEndpointStream, + _proposal: DeviceAuthorizationProposal, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.proposal_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(IdentityServiceOutcome::Accepted) }) + } + + fn checkpoint( + &self, + _authorized: krikos_identity::transport::AuthorizedEndpointStream, + _checkpoint: SignedCheckpoint, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.checkpoint_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(IdentityServiceOutcome::Accepted) }) + } + + fn transparency_gossip( + &self, + _remote_endpoint: EndpointPublicKey, + _head: SignedProviderHead, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.gossip_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(IdentityServiceOutcome::Accepted) }) + } + + fn recovery( + &self, + _remote_endpoint: EndpointPublicKey, + _proposal: RecoveryProposal, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.recovery_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(IdentityServiceOutcome::Accepted) }) + } +} + +#[derive(Debug, Default)] +struct BlockingPairingService { + entered: Arc, +} + +#[derive(Debug)] +struct ConcurrencyBoundPairingService { + calls: AtomicUsize, + active: AtomicUsize, + peak: AtomicUsize, + gate: Arc, +} + +impl Default for ConcurrencyBoundPairingService { + fn default() -> Self { + Self { + calls: AtomicUsize::new(0), + active: AtomicUsize::new(0), + peak: AtomicUsize::new(0), + gate: Arc::new(Semaphore::new(0)), + } + } +} + +impl IdentityProtocolService for ConcurrencyBoundPairingService { + fn pairing( + &self, + _transport: krikos_identity::AuthenticatedTransportBinding, + _ticket: PairingTicket, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.calls.fetch_add(1, Ordering::SeqCst); + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.peak.fetch_max(active, Ordering::SeqCst); + let gate = self.gate.clone(); + Box::pin(async move { + let permit = gate + .acquire_owned() + .await + .map_err(|_| IdentityError::Cancelled)?; + permit.forget(); + self.active.fetch_sub(1, Ordering::SeqCst); + Ok(IdentityServiceOutcome::Accepted) + }) + } +} + +#[derive(Debug)] +struct ConnectionCapture { + sender: std::sync::Mutex>>, +} + +impl ProtocolHandler for ConnectionCapture { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + let sender = self + .sender + .lock() + .map_err(|_| AcceptError::from_err(std::io::Error::other("capture lock poisoned")))? + .take() + .ok_or_else(|| { + AcceptError::from_err(std::io::Error::other("connection already captured")) + })?; + sender + .send(connection.clone()) + .map_err(|_| AcceptError::from_err(std::io::Error::other("capture receiver closed")))?; + connection.closed().await; + Ok(()) + } +} + +#[cfg(feature = "fs-store")] +#[derive(Debug)] +struct ReopeningNonceService { + path: std::path::PathBuf, +} + +#[cfg(feature = "fs-store")] +impl IdentityProtocolService for ReopeningNonceService { + fn pairing( + &self, + _transport: krikos_identity::AuthenticatedTransportBinding, + ticket: PairingTicket, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + let result = (|| { + use krikos_identity::{PairingNonceKey, PairingNonceStore, RedbPairingNonceStore}; + + let mut store = RedbPairingNonceStore::open(&self.path)?; + let key = PairingNonceKey::for_ticket(&ticket)?; + store.consume_atomically(key, ticket.expires_at()) + })(); + Box::pin(async move { + match result? { + krikos_identity::NonceConsumeResult::Consumed => { + Ok(IdentityServiceOutcome::Accepted) + } + krikos_identity::NonceConsumeResult::AlreadyConsumed => { + Ok(IdentityServiceOutcome::Rejected( + krikos_identity::net::ServiceRejectionCode::new(2)?, + )) + } + } + }) + } +} + +impl IdentityProtocolService for BlockingPairingService { + fn pairing( + &self, + _transport: krikos_identity::AuthenticatedTransportBinding, + _ticket: PairingTicket, + ) -> StoreFuture<'_, IdentityServiceOutcome> { + self.entered.notify_one(); + Box::pin(pending()) + } +} + +fn endpoint_key(endpoint_id: krikos::EndpointId) -> EndpointPublicKey { + EndpointPublicKey::new(SigningPublicKey::ed25519(*endpoint_id.as_bytes()).unwrap()) +} + +fn pairing_ticket(proposed_endpoint: krikos::EndpointId) -> PairingTicket { + let application = SecretKey::from_bytes(&[0x81; 32]); + let agreement = AgreementSecretKey::from_bytes([0x82; 32]); + let descriptor = DeviceDescriptor::new( + SigningPublicKey::ed25519(*application.public().as_bytes()).unwrap(), + agreement.public_key().unwrap(), + endpoint_key(proposed_endpoint), + Extensions::default(), + ) + .unwrap(); + let request = PairingTicketRequest::new( + typed_id(0x83), + descriptor, + Vec::new(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(601_000), + Extensions::default(), + ) + .unwrap(); + PairingTicket::issue_with_rng(request, &mut RepeatingRng(0x84)) + .unwrap() + .0 +} + +#[tokio::test] +async fn pairing_handler_dispatches_typed_request_and_rejects_endpoint_substitution() { + let controller = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let proposed = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let service = Arc::new(AcceptPairingService::default()); + let handlers = IdentityProtocolHandlers::new( + service.clone(), + Arc::new(CheckpointView { + endpoint: endpoint_key(proposed.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + Arc::new(MemoryAccountStore::new()), + CursorKey::new([0x91; 32]).unwrap(), + ); + let router = Router::builder(controller.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + handlers.handler(IdentityProtocolKind::Pairing), + ) + .spawn(); + + let connection = proposed + .connect(controller.addr(), krikos_identity::transport::PAIRING_ALPN) + .await + .unwrap(); + let (mut send, mut receive) = connection.open_bi().await.unwrap(); + let request = pairing_ticket(proposed.id()).to_canonical_bytes().unwrap(); + write_bounded_frame(&mut send, &request, 16 * 1024) + .await + .unwrap(); + let mut budget = SyncSessionBudget::new(); + let response = read_bounded_frame(&mut receive, &mut budget, 4 * 1024 * 1024) + .await + .unwrap(); + let reply = IdentityProtocolReply::from_canonical_bytes(&response).unwrap(); + let ack = reply.as_ack().unwrap(); + assert!(ack.accepted()); + assert_eq!(ack.protocol().unwrap(), IdentityProtocolKind::Pairing); + let ack_bytes = ack.to_canonical_bytes().unwrap(); + assert_eq!( + IdentityProtocolAck::from_canonical_bytes(&ack_bytes).unwrap(), + *ack, + ); + assert_eq!(service.pairing_calls.load(Ordering::SeqCst), 1); + + let connection = proposed + .connect(controller.addr(), krikos_identity::transport::PAIRING_ALPN) + .await + .unwrap(); + let (mut send, mut receive) = connection.open_bi().await.unwrap(); + let wrong_endpoint = SecretKey::from_bytes(&[0x92; 32]).public(); + let request = pairing_ticket(wrong_endpoint).to_canonical_bytes().unwrap(); + write_bounded_frame(&mut send, &request, 16 * 1024) + .await + .unwrap(); + let mut budget = SyncSessionBudget::new(); + assert!( + read_bounded_frame(&mut receive, &mut budget, 4 * 1024 * 1024) + .await + .is_err() + ); + assert_eq!(service.pairing_calls.load(Ordering::SeqCst), 1); + + router.shutdown().await.unwrap(); + proposed.close().await; +} + +#[derive(Debug)] +struct ExactCheckpointView { + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + endpoint: EndpointPublicKey, +} + +impl VerifiedCheckpointView for ExactCheckpointView { + fn device_endpoint( + &self, + account_id: AccountId, + checkpoint_id: CheckpointId, + device_id: DeviceId, + ) -> Result, IdentityError> { + if account_id != self.account_id + || checkpoint_id != self.checkpoint_id + || device_id != self.device_id + { + return Ok(None); + } + Ok(Some(CheckpointDeviceEndpoint::new( + self.endpoint, + ProjectedDeviceLifecycle::Active, + ))) + } +} + +async fn identity_round_trip( + client: &Endpoint, + server: krikos::EndpointAddr, + kind: IdentityProtocolKind, + request: &[u8], +) -> Result { + let connection = client + .connect(server, kind.alpn()) + .await + .map_err(|_| IdentityError::Cancelled)?; + let (mut send, mut receive) = connection + .open_bi() + .await + .map_err(|_| IdentityError::Cancelled)?; + write_bounded_frame(&mut send, request, 4 * 1024 * 1024).await?; + let mut budget = SyncSessionBudget::new(); + let response = read_bounded_frame(&mut receive, &mut budget, 4 * 1024 * 1024).await?; + IdentityProtocolReply::from_canonical_bytes(&response) +} + +fn exact_handlers( + _server: &Endpoint, + client: &Endpoint, + service: Arc, + store: Arc, + authorization: EndpointAuthorizationRequest, +) -> IdentityProtocolHandlers { + IdentityProtocolHandlers::new( + service, + Arc::new(ExactCheckpointView { + account_id: authorization.account_id(), + checkpoint_id: authorization.checkpoint_id(), + device_id: authorization.device_id(), + endpoint: endpoint_key(client.id()), + }), + store, + CursorKey::new([0xa1; 32]).unwrap(), + ) +} + +#[test] +fn endpoint_authorization_is_explicitly_versioned_and_rejects_legacy_bytes() { + let account_id = typed_id(0xca); + let checkpoint_id = typed_id(0xcb); + let device_id = typed_id(0xcc); + let request = EndpointAuthorizationRequest::new(account_id, checkpoint_id, device_id); + let encoded = request.to_canonical_bytes().unwrap(); + assert_eq!(encoded.first(), Some(&1)); + assert_eq!( + EndpointAuthorizationRequest::from_canonical_bytes(&encoded).unwrap(), + request + ); + + let legacy = postcard::to_stdvec(&(account_id, checkpoint_id, device_id)).unwrap(); + assert!(EndpointAuthorizationRequest::from_canonical_bytes(&legacy).is_err()); + let unsupported = postcard::to_stdvec(&(2_u16, account_id, checkpoint_id, device_id)).unwrap(); + assert!(matches!( + EndpointAuthorizationRequest::from_canonical_bytes(&unsupported), + Err(IdentityError::UnsupportedVersion { version: 2 }) + )); +} + +#[tokio::test] +async fn authorized_envelopes_reject_cross_account_canonical_bytes_before_dispatch() { + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let service = Arc::new(AcceptPairingService::default()); + let store = Arc::new(MemoryAccountStore::new()); + let genesis = + AccountGenesis::from_canonical_bytes(include_bytes!("vectors/account-genesis.bin")) + .unwrap(); + let sync_account_id = genesis.account_id().unwrap(); + store.create_account(genesis).await.unwrap(); + + let sync_authorization = + EndpointAuthorizationRequest::new(typed_id(0xd1), typed_id(0xd2), typed_id(0xd3)); + assert_ne!(sync_authorization.account_id(), sync_account_id); + let sync_request = + SyncRequest::new(sync_account_id, Vec::new(), None, 1, 4 * 1024 * 1024).unwrap(); + let sync_bytes = postcard::to_stdvec(&(sync_authorization, &sync_request)).unwrap(); + assert!(AuthorizedSyncRequest::from_canonical_bytes(&sync_bytes).is_err()); + + let proposal = DeviceAuthorizationProposal::from_canonical_bytes(include_bytes!( + "vectors/device-authorization-proposal.bin" + )) + .unwrap(); + let proposal_authorization = + EndpointAuthorizationRequest::new(typed_id(0xd4), typed_id(0xd5), typed_id(0xd6)); + assert_ne!(proposal_authorization.account_id(), proposal.account_id()); + let proposal_bytes = postcard::to_stdvec(&(proposal_authorization, &proposal)).unwrap(); + assert!(AuthorizedProposalRequest::from_canonical_bytes(&proposal_bytes).is_err()); + + let checkpoint = + SignedCheckpoint::from_canonical_bytes(include_bytes!("vectors/checkpoint-direct.bin")) + .unwrap(); + let checkpoint_authorization = + EndpointAuthorizationRequest::new(typed_id(0xd7), typed_id(0xd8), typed_id(0xd9)); + assert_ne!( + checkpoint_authorization.account_id(), + checkpoint.body().account_id() + ); + let checkpoint_bytes = postcard::to_stdvec(&(checkpoint_authorization, &checkpoint)).unwrap(); + assert!(AuthorizedCheckpointRequest::from_canonical_bytes(&checkpoint_bytes).is_err()); + + let sync_handlers = exact_handlers( + &server, + &client, + service.clone(), + store.clone(), + sync_authorization, + ); + let proposal_handlers = exact_handlers( + &server, + &client, + service.clone(), + store.clone(), + proposal_authorization, + ); + let checkpoint_handlers = exact_handlers( + &server, + &client, + service.clone(), + store, + checkpoint_authorization, + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::SYNC_ALPN, + sync_handlers.handler(IdentityProtocolKind::Sync), + ) + .accept( + krikos_identity::transport::PROPOSAL_ALPN, + proposal_handlers.handler(IdentityProtocolKind::Proposal), + ) + .accept( + krikos_identity::transport::CHECKPOINT_ALPN, + checkpoint_handlers.handler(IdentityProtocolKind::Checkpoint), + ) + .spawn(); + + for (kind, bytes) in [ + (IdentityProtocolKind::Sync, sync_bytes), + (IdentityProtocolKind::Proposal, proposal_bytes), + (IdentityProtocolKind::Checkpoint, checkpoint_bytes), + ] { + assert!( + identity_round_trip(&client, server.addr(), kind, &bytes) + .await + .is_err() + ); + } + assert_eq!(service.sync_calls.load(Ordering::SeqCst), 0); + assert_eq!(service.proposal_calls.load(Ordering::SeqCst), 0); + assert_eq!(service.checkpoint_calls.load(Ordering::SeqCst), 0); + + router.shutdown().await.unwrap(); + client.close().await; +} + +#[tokio::test] +async fn all_six_handlers_dispatch_bounded_canonical_requests_and_exact_authority() { + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let service = Arc::new(AcceptPairingService::default()); + let store = Arc::new(MemoryAccountStore::new()); + let genesis = + AccountGenesis::from_canonical_bytes(include_bytes!("vectors/account-genesis.bin")) + .unwrap(); + let account_id = genesis.account_id().unwrap(); + let snapshot = store.create_account(genesis).await.unwrap(); + let event = + AuthorizedEvent::from_canonical_bytes(include_bytes!("vectors/authorized-event.bin")) + .unwrap(); + let first_commit = store + .commit_event(snapshot.revision().clone(), event) + .await + .unwrap(); + let second = followup_event(first_commit.snapshot().state()); + store + .commit_event(first_commit.snapshot().revision().clone(), second) + .await + .unwrap(); + + let sync_authorization = + EndpointAuthorizationRequest::new(account_id, typed_id(0xa2), typed_id(0xa3)); + let authorization_bytes = sync_authorization.to_canonical_bytes().unwrap(); + assert_eq!( + EndpointAuthorizationRequest::from_canonical_bytes(&authorization_bytes).unwrap(), + sync_authorization, + ); + let sync_handlers = exact_handlers( + &server, + &client, + service.clone(), + store.clone(), + sync_authorization, + ); + let proposal = DeviceAuthorizationProposal::from_canonical_bytes(include_bytes!( + "vectors/device-authorization-proposal.bin" + )) + .unwrap(); + let proposal_authorization = + EndpointAuthorizationRequest::new(proposal.account_id(), typed_id(0xa4), typed_id(0xa5)); + let proposal_handlers = exact_handlers( + &server, + &client, + service.clone(), + store.clone(), + proposal_authorization, + ); + let checkpoint = + SignedCheckpoint::from_canonical_bytes(include_bytes!("vectors/checkpoint-direct.bin")) + .unwrap(); + let checkpoint_authorization = EndpointAuthorizationRequest::new( + checkpoint.body().account_id(), + typed_id(0xa6), + typed_id(0xa7), + ); + let checkpoint_handlers = exact_handlers( + &server, + &client, + service.clone(), + store.clone(), + checkpoint_authorization, + ); + let open_handlers = exact_handlers( + &server, + &client, + service.clone(), + store.clone(), + sync_authorization, + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + open_handlers.handler(IdentityProtocolKind::Pairing), + ) + .accept( + krikos_identity::transport::SYNC_ALPN, + sync_handlers.handler(IdentityProtocolKind::Sync), + ) + .accept( + krikos_identity::transport::PROPOSAL_ALPN, + proposal_handlers.handler(IdentityProtocolKind::Proposal), + ) + .accept( + krikos_identity::transport::CHECKPOINT_ALPN, + checkpoint_handlers.handler(IdentityProtocolKind::Checkpoint), + ) + .accept( + krikos_identity::transport::TRANSPARENCY_GOSSIP_ALPN, + open_handlers.handler(IdentityProtocolKind::TransparencyGossip), + ) + .accept( + krikos_identity::transport::RECOVERY_ALPN, + open_handlers.handler(IdentityProtocolKind::Recovery), + ) + .spawn(); + + let pairing = pairing_ticket(client.id()).to_canonical_bytes().unwrap(); + let reply = identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Pairing, + &pairing, + ) + .await + .unwrap(); + assert!(reply.as_ack().unwrap().accepted()); + + let request = SyncRequest::new(account_id, Vec::new(), None, 1, 4 * 1024 * 1024).unwrap(); + let sync = AuthorizedSyncRequest::new(sync_authorization, request) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let reply = identity_round_trip(&client, server.addr(), IdentityProtocolKind::Sync, &sync) + .await + .unwrap(); + let first_exchange_bytes = sync + .len() + .checked_add(4) + .and_then(|bytes| bytes.checked_add(reply.to_canonical_bytes().unwrap().len())) + .and_then(|bytes| bytes.checked_add(4)) + .unwrap(); + let first_sync = reply.as_sync().unwrap(); + let continuation = first_sync + .as_frame() + .unwrap() + .continuation() + .unwrap() + .clone(); + assert_eq!( + usize::try_from(continuation.delivered_bytes()).unwrap(), + first_exchange_bytes + ); + assert_eq!(first_sync.as_frame().unwrap().events().len(), 1); + let resumed = AuthorizedSyncRequest::new( + sync_authorization, + SyncRequest::new( + account_id, + Vec::new(), + Some(continuation), + 1, + 4 * 1024 * 1024, + ) + .unwrap(), + ) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let resumed = identity_round_trip(&client, server.addr(), IdentityProtocolKind::Sync, &resumed) + .await + .unwrap(); + assert_eq!( + resumed + .as_sync() + .unwrap() + .as_frame() + .unwrap() + .events() + .len(), + 1 + ); + assert!( + resumed + .as_sync() + .unwrap() + .as_frame() + .unwrap() + .continuation() + .is_none() + ); + + let proposal = AuthorizedProposalRequest::new(proposal_authorization, proposal) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let reply = identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Proposal, + &proposal, + ) + .await + .unwrap(); + assert!(reply.as_ack().unwrap().accepted()); + + let checkpoint = AuthorizedCheckpointRequest::new(checkpoint_authorization, checkpoint) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let reply = identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Checkpoint, + &checkpoint, + ) + .await + .unwrap(); + assert!(reply.as_ack().unwrap().accepted()); + + let head = SignedProviderHead::from_canonical_bytes(include_bytes!( + "vectors/signed-provider-head.bin" + )) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let reply = identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::TransparencyGossip, + &head, + ) + .await + .unwrap(); + assert!(reply.as_ack().unwrap().accepted()); + + let recovery = + RecoveryProposal::from_canonical_bytes(include_bytes!("vectors/recovery-proposal.bin")) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let reply = identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Recovery, + &recovery, + ) + .await + .unwrap(); + assert!(reply.as_ack().unwrap().accepted()); + + let wrong_sync = AuthorizedSyncRequest::new( + EndpointAuthorizationRequest::new(account_id, typed_id(0xff), typed_id(0xa3)), + SyncRequest::new(account_id, Vec::new(), None, 1, 4 * 1024 * 1024).unwrap(), + ) + .unwrap() + .to_canonical_bytes() + .unwrap(); + assert!( + identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Sync, + &wrong_sync, + ) + .await + .is_err() + ); + + let wrong_device = AuthorizedSyncRequest::new( + EndpointAuthorizationRequest::new( + account_id, + sync_authorization.checkpoint_id(), + typed_id(0xfe), + ), + SyncRequest::new(account_id, Vec::new(), None, 1, 4 * 1024 * 1024).unwrap(), + ) + .unwrap() + .to_canonical_bytes() + .unwrap(); + assert!( + identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Sync, + &wrong_device, + ) + .await + .is_err() + ); + + assert_eq!(service.pairing_calls.load(Ordering::SeqCst), 1); + assert_eq!(service.sync_calls.load(Ordering::SeqCst), 2); + assert_eq!(service.proposal_calls.load(Ordering::SeqCst), 1); + assert_eq!(service.checkpoint_calls.load(Ordering::SeqCst), 1); + assert_eq!(service.gossip_calls.load(Ordering::SeqCst), 1); + assert_eq!(service.recovery_calls.load(Ordering::SeqCst), 1); + + router.shutdown().await.unwrap(); + client.close().await; +} + +#[tokio::test] +async fn pairing_proposal_and_sync_use_repository_local_relay_only_addresses() { + let (relay_map, _relay_url, _relay_guard) = + krikos::test_utils::run_relay_server().await.unwrap(); + let controller = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Custom(relay_map.clone())) + .ca_tls_config(krikos::tls::CaTlsConfig::insecure_skip_verify()) + .bind() + .await + .unwrap(); + let proposed = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Custom(relay_map)) + .ca_tls_config(krikos::tls::CaTlsConfig::insecure_skip_verify()) + .bind() + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(10), controller.online()) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(10), proposed.online()) + .await + .unwrap(); + + let service = Arc::new(AcceptPairingService::default()); + let store = Arc::new(MemoryAccountStore::new()); + let genesis = + AccountGenesis::from_canonical_bytes(include_bytes!("vectors/account-genesis.bin")) + .unwrap(); + let account_id = genesis.account_id().unwrap(); + store.create_account(genesis).await.unwrap(); + let handlers = IdentityProtocolHandlers::new( + service.clone(), + Arc::new(CheckpointView { + endpoint: endpoint_key(proposed.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + store, + CursorKey::new([0xb1; 32]).unwrap(), + ); + let router = Router::builder(controller.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + handlers.handler(IdentityProtocolKind::Pairing), + ) + .accept( + krikos_identity::transport::SYNC_ALPN, + handlers.handler(IdentityProtocolKind::Sync), + ) + .accept( + krikos_identity::transport::PROPOSAL_ALPN, + handlers.handler(IdentityProtocolKind::Proposal), + ) + .spawn(); + let mut relay_only = controller.addr(); + relay_only.addrs.retain(krikos::TransportAddr::is_relay); + assert!(!relay_only.addrs.is_empty()); + + let request = pairing_ticket(proposed.id()).to_canonical_bytes().unwrap(); + let reply = identity_round_trip( + &proposed, + relay_only.clone(), + IdentityProtocolKind::Pairing, + &request, + ) + .await + .unwrap(); + assert!(reply.as_ack().unwrap().accepted()); + assert_eq!(service.pairing_calls.load(Ordering::SeqCst), 1); + + let authorization = + EndpointAuthorizationRequest::new(account_id, typed_id(0xb2), typed_id(0xb3)); + let sync = AuthorizedSyncRequest::new( + authorization, + SyncRequest::new(account_id, Vec::new(), None, 1, 4 * 1024 * 1024).unwrap(), + ) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let reply = identity_round_trip( + &proposed, + relay_only.clone(), + IdentityProtocolKind::Sync, + &sync, + ) + .await + .unwrap(); + assert!(reply.as_sync().is_some()); + + let proposal = DeviceAuthorizationProposal::from_canonical_bytes(include_bytes!( + "vectors/device-authorization-proposal.bin" + )) + .unwrap(); + let proposal = AuthorizedProposalRequest::new( + EndpointAuthorizationRequest::new(proposal.account_id(), typed_id(0xb4), typed_id(0xb5)), + proposal, + ) + .unwrap() + .to_canonical_bytes() + .unwrap(); + let reply = identity_round_trip( + &proposed, + relay_only, + IdentityProtocolKind::Proposal, + &proposal, + ) + .await + .unwrap(); + assert!(reply.as_ack().unwrap().accepted()); + assert_eq!(service.sync_calls.load(Ordering::SeqCst), 1); + assert_eq!(service.proposal_calls.load(Ordering::SeqCst), 1); + + router.shutdown().await.unwrap(); + proposed.close().await; +} + +#[tokio::test] +async fn router_shutdown_cancels_pending_read_and_pending_service_without_detaching() { + async fn endpoints() -> (Endpoint, Endpoint) { + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + (server, client) + } + + let (server, client) = endpoints().await; + let service = Arc::new(BlockingPairingService::default()); + let handlers = IdentityProtocolHandlers::new( + service.clone(), + Arc::new(CheckpointView { + endpoint: endpoint_key(client.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + Arc::new(MemoryAccountStore::new()), + CursorKey::new([0xc1; 32]).unwrap(), + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + handlers.handler(IdentityProtocolKind::Pairing), + ) + .spawn(); + let connection = client + .connect(server.addr(), krikos_identity::transport::PAIRING_ALPN) + .await + .unwrap(); + let (mut send, _receive) = connection.open_bi().await.unwrap(); + let request = pairing_ticket(client.id()).to_canonical_bytes().unwrap(); + write_bounded_frame(&mut send, &request, 16 * 1024) + .await + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + service.entered.notified(), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), router.shutdown()) + .await + .unwrap() + .unwrap(); + client.close().await; + + let (server, client) = endpoints().await; + let handlers = IdentityProtocolHandlers::new( + Arc::new(AcceptPairingService::default()), + Arc::new(CheckpointView { + endpoint: endpoint_key(client.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + Arc::new(MemoryAccountStore::new()), + CursorKey::new([0xc2; 32]).unwrap(), + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + handlers.handler(IdentityProtocolKind::Pairing), + ) + .spawn(); + let connection = client + .connect(server.addr(), krikos_identity::transport::PAIRING_ALPN) + .await + .unwrap(); + let (mut send, _receive) = connection.open_bi().await.unwrap(); + send.write_all(&64_u32.to_be_bytes()).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), router.shutdown()) + .await + .unwrap() + .unwrap(); + client.close().await; +} + +#[cfg(feature = "fs-store")] +#[tokio::test] +async fn pairing_nonce_replay_is_rejected_after_redb_reopen_between_connections() { + let directory = tempfile::tempdir().unwrap(); + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let handlers = IdentityProtocolHandlers::new( + Arc::new(ReopeningNonceService { + path: directory.path().join("pairing-nonces.redb"), + }), + Arc::new(CheckpointView { + endpoint: endpoint_key(client.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + Arc::new(MemoryAccountStore::new()), + CursorKey::new([0xd1; 32]).unwrap(), + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + handlers.handler(IdentityProtocolKind::Pairing), + ) + .spawn(); + let request = pairing_ticket(client.id()).to_canonical_bytes().unwrap(); + + let first = identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Pairing, + &request, + ) + .await + .unwrap(); + assert!(first.as_ack().unwrap().accepted()); + + let replay = identity_round_trip( + &client, + server.addr(), + IdentityProtocolKind::Pairing, + &request, + ) + .await + .unwrap(); + let replay_ack = replay.as_ack().unwrap(); + assert!(!replay_ack.accepted()); + assert_eq!(replay_ack.rejection_code().unwrap().unwrap().get(), 2); + + router.shutdown().await.unwrap(); + client.close().await; +} + +#[test] +fn concrete_protocol_kinds_commit_all_six_exact_alpns() { + assert_eq!( + IdentityProtocolKind::Pairing.alpn(), + krikos_identity::transport::PAIRING_ALPN + ); + assert_eq!( + IdentityProtocolKind::Sync.alpn(), + krikos_identity::transport::SYNC_ALPN + ); + assert_eq!( + IdentityProtocolKind::Proposal.alpn(), + krikos_identity::transport::PROPOSAL_ALPN + ); + assert_eq!( + IdentityProtocolKind::Checkpoint.alpn(), + krikos_identity::transport::CHECKPOINT_ALPN + ); + assert_eq!( + IdentityProtocolKind::TransparencyGossip.alpn(), + krikos_identity::transport::TRANSPARENCY_GOSSIP_ALPN, + ); + assert_eq!( + IdentityProtocolKind::Recovery.alpn(), + krikos_identity::transport::RECOVERY_ALPN + ); +} + +#[tokio::test] +async fn pairing_router_rejects_wrong_alpn_during_handshake() { + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let handlers = IdentityProtocolHandlers::new( + Arc::new(AcceptPairingService::default()), + Arc::new(CheckpointView { + endpoint: endpoint_key(client.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + Arc::new(MemoryAccountStore::new()), + CursorKey::new([0xe1; 32]).unwrap(), + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + handlers.handler(IdentityProtocolKind::Pairing), + ) + .spawn(); + + assert!( + client + .connect(server.addr(), krikos_identity::transport::SYNC_ALPN) + .await + .is_err() + ); + router.shutdown().await.unwrap(); + client.close().await; +} + +#[tokio::test] +async fn concrete_handler_rejects_a_completed_connection_with_another_alpn() { + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let (sender, receiver) = oneshot::channel(); + let capture = ConnectionCapture { + sender: std::sync::Mutex::new(Some(sender)), + }; + let router = Router::builder(server.clone()) + .accept(krikos_identity::transport::PAIRING_ALPN, capture) + .spawn(); + let client_connection = client + .connect(server.addr(), krikos_identity::transport::PAIRING_ALPN) + .await + .unwrap(); + let server_connection = receiver.await.unwrap(); + let handlers = IdentityProtocolHandlers::new( + Arc::new(AcceptPairingService::default()), + Arc::new(CheckpointView { + endpoint: endpoint_key(client.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + Arc::new(MemoryAccountStore::new()), + CursorKey::new([0xe2; 32]).unwrap(), + ); + + assert!( + handlers + .handler(IdentityProtocolKind::Sync) + .accept(server_connection) + .await + .is_err() + ); + + client_connection.close(0_u32.into(), b"mismatch observed"); + router.shutdown().await.unwrap(); + client.close().await; +} + +#[tokio::test] +async fn shared_handler_admission_caps_aggregate_network_service_concurrency() { + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let service = Arc::new(ConcurrencyBoundPairingService::default()); + let handlers = IdentityProtocolHandlers::new( + service.clone(), + Arc::new(CheckpointView { + endpoint: endpoint_key(client.id()), + lifecycle: ProjectedDeviceLifecycle::Active, + }), + Arc::new(MemoryAccountStore::new()), + CursorKey::new([0xe3; 32]).unwrap(), + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::PAIRING_ALPN, + handlers.handler(IdentityProtocolKind::Pairing), + ) + .spawn(); + let request = Arc::new(pairing_ticket(client.id()).to_canonical_bytes().unwrap()); + let mut tasks = JoinSet::new(); + for _ in 0..=krikos_identity::limits::MAX_CONCURRENT_IDENTITY_TASKS { + let client = client.clone(); + let server = server.addr(); + let request = request.clone(); + tasks.spawn(async move { + identity_round_trip( + &client, + server, + IdentityProtocolKind::Pairing, + request.as_slice(), + ) + .await + }); + } + + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while service.calls.load(Ordering::SeqCst) + < krikos_identity::limits::MAX_CONCURRENT_IDENTITY_TASKS + { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert_eq!( + service.calls.load(Ordering::SeqCst), + krikos_identity::limits::MAX_CONCURRENT_IDENTITY_TASKS + ); + assert_eq!( + service.peak.load(Ordering::SeqCst), + krikos_identity::limits::MAX_CONCURRENT_IDENTITY_TASKS + ); + + service.gate.add_permits(1); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while service.calls.load(Ordering::SeqCst) + <= krikos_identity::limits::MAX_CONCURRENT_IDENTITY_TASKS + { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + service + .gate + .add_permits(krikos_identity::limits::MAX_CONCURRENT_IDENTITY_TASKS); + while let Some(result) = tasks.join_next().await { + assert!(result.unwrap().unwrap().as_ack().unwrap().accepted()); + } + assert_eq!( + service.peak.load(Ordering::SeqCst), + krikos_identity::limits::MAX_CONCURRENT_IDENTITY_TASKS + ); + + router.shutdown().await.unwrap(); + client.close().await; +} + +#[tokio::test] +async fn network_sync_session_rejects_cumulative_request_and_framing_overflow() { + let server = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let client = Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap(); + let service = Arc::new(AcceptPairingService::default()); + let store = Arc::new(MemoryAccountStore::new()); + let genesis = + AccountGenesis::from_canonical_bytes(include_bytes!("vectors/account-genesis.bin")) + .unwrap(); + let account_id = genesis.account_id().unwrap(); + let snapshot = store.create_account(genesis).await.unwrap(); + let authorization = + EndpointAuthorizationRequest::new(account_id, typed_id(0xe4), typed_id(0xe5)); + let key = CursorKey::new([0xe6; 32]).unwrap(); + let continuation = krikos_identity::SyncCursor::issue( + &key, + account_id, + snapshot.revision().heads().to_vec(), + 0, + krikos_identity::limits::MAX_SYNC_SESSION_BYTES - 1, + ) + .unwrap(); + let handlers = IdentityProtocolHandlers::new( + service.clone(), + Arc::new(ExactCheckpointView { + account_id, + checkpoint_id: authorization.checkpoint_id(), + device_id: authorization.device_id(), + endpoint: endpoint_key(client.id()), + }), + store, + CursorKey::new([0xe6; 32]).unwrap(), + ); + let router = Router::builder(server.clone()) + .accept( + krikos_identity::transport::SYNC_ALPN, + handlers.handler(IdentityProtocolKind::Sync), + ) + .spawn(); + let request = AuthorizedSyncRequest::new( + authorization, + SyncRequest::new( + account_id, + Vec::new(), + Some(continuation), + 1, + 4 * 1024 * 1024, + ) + .unwrap(), + ) + .unwrap() + .to_canonical_bytes() + .unwrap(); + + assert!( + identity_round_trip(&client, server.addr(), IdentityProtocolKind::Sync, &request) + .await + .is_err() + ); + assert_eq!(service.sync_calls.load(Ordering::SeqCst), 0); + + router.shutdown().await.unwrap(); + client.close().await; +} + +#[tokio::test] +async fn network_length_prefix_is_rejected_before_payload_allocation() { + let (mut sender, mut receiver) = duplex(16); + sender.write_all(&65_u32.to_be_bytes()).await.unwrap(); + drop(sender); + + let mut budget = SyncSessionBudget::new(); + assert!(matches!( + read_bounded_frame(&mut receiver, &mut budget, 64).await, + Err(IdentityError::LimitExceeded { .. }) + )); + assert_eq!(budget.consumed_bytes(), 0); +} + +#[tokio::test] +async fn bounded_frame_round_trip_charges_exact_payload_and_rejects_zero_limit() { + let payload = b"bounded identity frame"; + let (mut sender, mut receiver) = duplex(128); + write_bounded_frame(&mut sender, payload, 64).await.unwrap(); + let mut budget = SyncSessionBudget::new(); + assert_eq!( + read_bounded_frame(&mut receiver, &mut budget, 64) + .await + .unwrap(), + payload + ); + assert_eq!(budget.consumed_bytes(), payload.len() + 4); + + assert!(matches!( + write_bounded_frame(&mut sink(), &[], 0).await, + Err(IdentityError::LimitExceeded { .. }) + )); +} + +#[tokio::test] +async fn supervisor_enforces_queue_bound_and_observable_cancellation() { + let mut supervisor = IdentityTaskSupervisor::new(); + for _ in 0..256 { + supervisor + .submit(async { pending::>().await }) + .unwrap(); + } + assert_eq!( + supervisor.submit(async { Ok(()) }), + Err(IdentityError::ResourceBusy) + ); + assert_eq!(supervisor.shutdown().await, Err(IdentityError::Cancelled)); +} + +#[tokio::test] +async fn supervisor_reports_child_failure_before_shutdown() { + let mut supervisor = IdentityTaskSupervisor::new(); + supervisor + .submit(async { Err(IdentityError::InvalidProof) }) + .unwrap(); + assert_eq!( + supervisor.join_next().await, + Some(Err(IdentityError::InvalidProof)) + ); + assert_eq!(supervisor.shutdown().await, Ok(())); +} + +struct CheckpointView { + endpoint: EndpointPublicKey, + lifecycle: ProjectedDeviceLifecycle, +} + +impl VerifiedCheckpointView for CheckpointView { + fn device_endpoint( + &self, + _account_id: AccountId, + _checkpoint_id: CheckpointId, + _device_id: DeviceId, + ) -> Result, IdentityError> { + Ok(Some(CheckpointDeviceEndpoint::new( + self.endpoint, + self.lifecycle, + ))) + } +} + +#[test] +fn endpoint_dispatch_requires_exact_key_and_active_checkpoint_device() { + let account_id = typed_id(1); + let checkpoint_id = typed_id(2); + let device_id = typed_id(3); + let expected = endpoint(4); + let active = CheckpointView { + endpoint: expected, + lifecycle: ProjectedDeviceLifecycle::Active, + }; + let authorized = + authorize_endpoint_stream(&active, account_id, checkpoint_id, device_id, expected).unwrap(); + assert_eq!(authorized.endpoint_key(), expected); + assert_eq!( + authorize_endpoint_stream(&active, account_id, checkpoint_id, device_id, endpoint(5),), + Err(IdentityError::DeviceNotAuthorized) + ); + + for lifecycle in [ + ProjectedDeviceLifecycle::Suspended, + ProjectedDeviceLifecycle::Revoked, + ] { + let inactive = CheckpointView { + endpoint: expected, + lifecycle, + }; + assert!( + authorize_endpoint_stream(&inactive, account_id, checkpoint_id, device_id, expected,) + .is_err() + ); + } +} diff --git a/protocols/krikos-identity/tests/network_fuzz_corpus.rs b/protocols/krikos-identity/tests/network_fuzz_corpus.rs new file mode 100644 index 00000000000..e9520732959 --- /dev/null +++ b/protocols/krikos-identity/tests/network_fuzz_corpus.rs @@ -0,0 +1,196 @@ +#![cfg(feature = "net")] + +use krikos_identity::{ + CanonicalWire, DeviceAuthorizationProposal, SignedCheckpoint, SyncCursor, SyncFrame, + SyncRequest, SyncResponse, + net::{ + AuthorizedCheckpointRequest, AuthorizedProposalRequest, AuthorizedSyncRequest, + EndpointAuthorizationRequest, IdentityProtocolAck, IdentityProtocolReply, + }, +}; + +fn payload(seed: &[u8], selector: u8) -> &[u8] { + assert_eq!(seed.first(), Some(&selector)); + &seed[1..] +} + +fn assert_corpus_pair(accepted: &[u8], rejected: &[u8], selector: u8) { + assert!(T::from_canonical_bytes(payload(accepted, selector)).is_ok()); + assert!(T::from_canonical_bytes(payload(rejected, selector)).is_err()); +} + +#[test] +fn network_schema_corpus_keeps_exact_selectors_and_accept_reject_baselines() { + assert_corpus_pair::( + include_bytes!("../../../fuzz/corpus/identity_sync/selector-00-sync-request-accepted.bin"), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-00-sync-request-rejected-truncated.bin" + ), + 0, + ); + for rejected in [ + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-00-sync-request-rejected-duplicate-head.bin" + ) + .as_slice(), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-00-sync-request-rejected-unsupported-version.bin" + ) + .as_slice(), + ] { + assert!(SyncRequest::from_canonical_bytes(payload(rejected, 0)).is_err()); + } + + assert_corpus_pair::( + include_bytes!("../../../fuzz/corpus/identity_sync/selector-01-sync-frame-accepted.bin"), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-truncated.bin" + ), + 1, + ); + for rejected in [ + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-duplicate-head.bin" + ) + .as_slice(), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-01-sync-frame-rejected-unsupported-version.bin" + ) + .as_slice(), + ] { + assert!(SyncFrame::from_canonical_bytes(payload(rejected, 1)).is_err()); + } + + assert_corpus_pair::( + include_bytes!("../../../fuzz/corpus/identity_sync/selector-02-sync-cursor-accepted.bin"), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-truncated.bin" + ), + 2, + ); + for rejected in [ + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-duplicate-head.bin" + ) + .as_slice(), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-02-sync-cursor-rejected-unsupported-version.bin" + ) + .as_slice(), + ] { + assert!(SyncCursor::from_canonical_bytes(payload(rejected, 2)).is_err()); + } + + assert_corpus_pair::( + include_bytes!("../../../fuzz/corpus/identity_sync/selector-03-sync-response-accepted.bin"), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-03-sync-response-rejected-truncated.bin" + ), + 3, + ); + for rejected in [ + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-03-sync-response-rejected-legacy-ordinal.bin" + ) + .as_slice(), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-codepoint.bin" + ) + .as_slice(), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-03-sync-response-rejected-unsupported-version.bin" + ) + .as_slice(), + ] { + assert!(SyncResponse::from_canonical_bytes(payload(rejected, 3)).is_err()); + } + + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-04-endpoint-authorization-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-04-endpoint-authorization-rejected-truncated.bin" + ), + 4, + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-05-authorized-sync-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-05-authorized-sync-rejected-truncated.bin" + ), + 5, + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-06-authorized-proposal-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-06-authorized-proposal-rejected-truncated.bin" + ), + 6, + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-07-authorized-checkpoint-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-07-authorized-checkpoint-rejected-truncated.bin" + ), + 7, + ); + + let sync_mismatch = include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-05-authorized-sync-rejected-account-mismatch.bin" + ); + let (sync_authorization, sync_request): (EndpointAuthorizationRequest, SyncRequest) = + postcard::from_bytes(payload(sync_mismatch, 5)).unwrap(); + assert_ne!(sync_authorization.account_id(), sync_request.account_id()); + assert!(AuthorizedSyncRequest::from_canonical_bytes(payload(sync_mismatch, 5)).is_err()); + + let proposal_mismatch = include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-06-authorized-proposal-rejected-account-mismatch.bin" + ); + let (proposal_authorization, proposal): ( + EndpointAuthorizationRequest, + DeviceAuthorizationProposal, + ) = postcard::from_bytes(payload(proposal_mismatch, 6)).unwrap(); + assert_ne!(proposal_authorization.account_id(), proposal.account_id()); + assert!( + AuthorizedProposalRequest::from_canonical_bytes(payload(proposal_mismatch, 6)).is_err() + ); + + let checkpoint_mismatch = include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-07-authorized-checkpoint-rejected-account-mismatch.bin" + ); + let (checkpoint_authorization, checkpoint): (EndpointAuthorizationRequest, SignedCheckpoint) = + postcard::from_bytes(payload(checkpoint_mismatch, 7)).unwrap(); + assert_ne!( + checkpoint_authorization.account_id(), + checkpoint.body().account_id() + ); + assert!( + AuthorizedCheckpointRequest::from_canonical_bytes(payload(checkpoint_mismatch, 7)).is_err() + ); + + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-08-identity-protocol-ack-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-08-identity-protocol-ack-rejected-truncated.bin" + ), + 8, + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_sync/selector-09-identity-protocol-reply-rejected-truncated.bin" + ), + 9, + ); +} diff --git a/protocols/krikos-identity/tests/operational_recovery.rs b/protocols/krikos-identity/tests/operational_recovery.rs new file mode 100644 index 00000000000..e6048a1e377 --- /dev/null +++ b/protocols/krikos-identity/tests/operational_recovery.rs @@ -0,0 +1,1420 @@ +#![cfg(all(feature = "fs-store", feature = "provider-store"))] + +use std::{ + collections::BTreeSet, + convert::Infallible, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use futures_lite::future::block_on; +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountId, AccountOperation, AccountState, AccountStore, AdmissionEvidence, + AgreementSecretKey, AlgorithmSignature, ApplicationId, BeginRecovery, CanonicalWire, + CheckpointAuthorization, CheckpointId, ClaimEffects, ControlPolicy, ControllerApprovalBody, + ControllerApprovals, ControllerClass, ControllerDescriptor, ControllerKeyId, ControllerScope, + ControllerSelector, ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, + DelayEvidence, DeviceAuthorization, DeviceClass, DeviceDescriptor, Digest, DurationMillis, + EffectFailure, EffectId, EffectRecord, EffectStatus, EndpointPublicKey, Epoch, EventBody, + EventIntentApprovalBody, EventIntentApprovals, EventPredecessors, Extensions, FinalizeRecovery, + FreshnessEvidence, FreshnessRequirement, GroupId, GroupKey, GroupKeyDistributionSnapshot, + GroupKeyEpoch, HashAlgorithm, IdentityError, InclusionReceipt, KeyedSignature, LeaseId, + MemoryAccountStore, MemoryOperationalEffectStore, OperationKind, + OperationalCheckpointAuthorizer, OperationalCheckpointBuild, OperationalEffectJournal, + OperationalEffectPhase, OperationalEffectStore, OperationalPeerNotifier, PolicyRule, + ProjectionEffect, ProjectionLifecycle, ProtocolSignature, ProtocolVersion, + ProviderAdmissionControl, ProviderAdmissionRequest, ProviderDescriptor, ProviderFreshness, + ProviderHeadBody, ProviderHeadSigner, ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, + ProviderLogSubject, ProviderPolicy, ProviderPolicyVersion, ProviderQuorum, ProviderReceipts, + PublicationStage, PublicationTracker, RecoveryAuthority, RecoveryAuthorityPlan, + RecoveryDelayAnchor, RecoveryId, RecoveryPolicy, RecoveryPolicyVersion, RecoveryProposal, + RecoveryThresholdEvidence, RedbAccountStore, RedbOperationalEffectStore, RedbProviderStore, + RequiredWeight, Sequence, SignedCheckpoint, SignedControllerApproval, + SignedEventIntentApproval, SignedProviderHead, SigningPublicKey, StoreFuture, Timestamp, + authorize_provider_append, build_authorize_and_commit_checkpoint, build_checkpoint_body, + build_provider_checkpoint_bundle_from_genesis, complete_ready_effect, + merkle::MerkleConsistencyProof, rotate_group_key_with_rng, verify_checkpoint, +}; +use rand_core::{TryCryptoRng, TryRng}; +use redb::{Database, ReadableTable, TableDefinition}; + +const TEST_OPERATION_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-operational-effects-v1"); + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +struct ProviderSigner(SecretKey); + +impl ProviderHeadSigner for ProviderSigner { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +struct AllowProviderAdmission; + +impl ProviderAdmissionControl for AllowProviderAdmission { + fn check( + &self, + _admission: krikos_identity::ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +#[derive(Clone, Default)] +struct IdempotentNotifier { + notified: Arc>>, +} + +impl IdempotentNotifier { + fn unique_notifications(&self) -> usize { + self.notified.lock().unwrap().len() + } +} + +impl OperationalPeerNotifier for IdempotentNotifier { + fn notify<'a>(&'a self, effect: &'a EffectRecord) -> StoreFuture<'a, ()> { + let result = self + .notified + .lock() + .map_err(|_| IdentityError::StorageCorruption) + .map(|mut notified| { + notified.insert(effect.id()); + }); + Box::pin(async move { result }) + } +} + +fn digest(fill: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [fill; 32]) +} + +fn typed_id(fill: u8) -> T { + T::from_canonical_bytes(&digest(fill).to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn provider(secret: &SecretKey) -> ProviderDescriptor { + ProviderDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +fn rule(operation: OperationKind, provider_freshness: bool) -> PolicyRule { + let freshness = if provider_freshness { + FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(2).unwrap(), DurationMillis::new(1_000)) + .unwrap(), + ) + } else { + FreshnessRequirement::latest_known() + }; + PolicyRule::new( + operation, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + freshness, + None, + Extensions::default(), + ) + .unwrap() +} + +fn fixture( + first_provider: &ProviderDescriptor, + second_provider: &ProviderDescriptor, +) -> (AccountGenesis, SecretKey) { + let controller_secret = SecretKey::from_bytes(&[0x11; 32]); + let control_policy = ControlPolicy::new( + vec![ + rule(OperationKind::AuthorizeDevice, false), + rule(OperationKind::BeginRecovery, false), + rule(OperationKind::CancelRecovery, false), + rule(OperationKind::FinalizeRecovery, true), + ], + Extensions::default(), + ) + .unwrap(); + let recovery_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let provider_policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![first_provider.clone(), second_provider.clone()], + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(2).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x12; 32], + Timestamp::from_unix_millis(1), + control_policy, + vec![controller(&controller_secret)], + recovery_policy, + provider_policy, + Extensions::default(), + ) + .unwrap(); + (genesis, controller_secret) +} + +fn device_authorization(agreement_secret: &AgreementSecretKey) -> DeviceAuthorization { + let application_secret = SecretKey::from_bytes(&[0x21; 32]); + let endpoint_secret = SecretKey::from_bytes(&[0x22; 32]); + let descriptor = DeviceDescriptor::new( + SigningPublicKey::ed25519(*application_secret.public().as_bytes()).unwrap(), + agreement_secret.public_key().unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*endpoint_secret.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap(); + DeviceAuthorization::new( + descriptor.id().unwrap(), + descriptor, + DeviceClass::ApplicationOnly, + None, + Vec::new(), + Epoch::new(1), + Extensions::default(), + ) + .unwrap() +} + +fn controller_intent_approvals( + state: &AccountState, + body: &EventBody, + signer: &SecretKey, +) -> EventIntentApprovals { + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let body = EventIntentApprovalBody::new( + controller_id, + body.proposal_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&body.to_canonical_bytes().unwrap()); + EventIntentApprovals::new(vec![ + SignedEventIntentApproval::new( + body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(), + ]) + .unwrap() +} + +fn authorize_with_evidence( + state: &AccountState, + body: EventBody, + evidence: AdmissionEvidence, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let event_id = evidence.event_id_for_body(&body).unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn authorized_event( + state: &AccountState, + operation: AccountOperation, + nonce: u8, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let epoch = state.expected_epoch_for(&operation).unwrap(); + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + epoch, + predecessors, + operation, + Timestamp::from_unix_millis(u64::from(nonce)), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x31); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + authorize_with_evidence(state, body, evidence, signer) +} + +#[allow(clippy::too_many_arguments)] +fn provider_receipt( + provider_secret: &SecretKey, + provider: &ProviderDescriptor, + account_id: krikos_identity::AccountId, + subject: ProviderLogSubject, + log_fill: u8, + entry_observed_at: u64, + head_observed_at: u64, +) -> InclusionReceipt { + let log_id = typed_id::(log_fill); + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + log_id, + account_id, + subject, + Timestamp::from_unix_millis(entry_observed_at), + Extensions::default(), + ) + .unwrap(); + let head = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + 1, + entry.merkle_leaf_hash().unwrap(), + Timestamp::from_unix_millis(head_observed_at), + Extensions::default(), + ) + .unwrap(); + let signature = provider_secret.sign(&head.signing_bytes().unwrap()); + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(head, ProtocolSignature::ed25519(signature.to_bytes())), + ) + .unwrap() +} + +fn begin_recovery_event( + state: &AccountState, + signer: &SecretKey, + retained_device: krikos_identity::DeviceId, + providers: &[(&SecretKey, &ProviderDescriptor, u8)], +) -> (krikos_identity::AuthorizedEvent, RecoveryId) { + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x32), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0x33; 32], + vec![controller(signer)], + state.control_policy().clone(), + state.recovery_policy().clone(), + vec![retained_device], + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap(); + let recovery_id = proposal.recovery_id().unwrap(); + let operation = AccountOperation::BeginRecovery( + BeginRecovery::try_new( + ProtocolVersion::V1, + proposal, + RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.expected_epoch_for(&operation).unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(100), + [0x34; 16], + Extensions::default(), + ) + .unwrap(); + let proposal_id = body.proposal_id().unwrap(); + let delay_receipts = providers + .iter() + .map(|(secret, descriptor, log_fill)| { + provider_receipt( + secret, + descriptor, + state.account_id(), + ProviderLogSubject::EventIntent(proposal_id), + *log_fill, + 100, + 100, + ) + }) + .collect(); + let checkpoint_id = typed_id::(0x32); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::provider_quorum( + state.provider_policy_id(), + ProviderQuorum::new(2).unwrap(), + controller_intent_approvals(state, &body, signer), + ProviderReceipts::new(delay_receipts).unwrap(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + ( + authorize_with_evidence(state, body, evidence, signer), + recovery_id, + ) +} + +fn finalize_recovery_event( + state: &AccountState, + recovery_id: RecoveryId, + begin_proposal_id: krikos_identity::ProposalId, + providers: &[(&SecretKey, &ProviderDescriptor, u8)], +) -> krikos_identity::AuthorizedEvent { + let anchor_receipts = providers + .iter() + .map(|(secret, descriptor, log_fill)| { + provider_receipt( + secret, + descriptor, + state.account_id(), + ProviderLogSubject::EventIntent(begin_proposal_id), + *log_fill, + 100, + 110, + ) + }) + .collect(); + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + begin_proposal_id, + state.provider_policy_id(), + ProviderQuorum::new(2).unwrap(), + ProviderReceipts::new(anchor_receipts).unwrap(), + Extensions::default(), + ) + .unwrap(); + let operation = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor, + Timestamp::from_unix_millis(110), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.epoch().checked_next().unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(110), + [0x36; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x37); + let completion_receipts = providers + .iter() + .enumerate() + .map(|(index, (secret, descriptor, _))| { + provider_receipt( + secret, + descriptor, + state.account_id(), + ProviderLogSubject::Checkpoint(checkpoint_id), + 0x40 + u8::try_from(index).unwrap(), + 100, + 110, + ) + }) + .collect(); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::provider_quorum( + checkpoint_id, + state.provider_policy_id(), + ProviderReceipts::new(completion_receipts).unwrap(), + ) + .unwrap(), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(Vec::new()).unwrap(), + ) + .unwrap() +} + +fn effect_for_event( + effects: &[EffectRecord], + event_id: krikos_identity::EventId, + matches_kind: impl Fn(ProjectionEffect) -> bool, +) -> EffectRecord { + effects + .iter() + .find(|effect| { + let effect_event_id = match effect.effect() { + ProjectionEffect::PublishAccountEvent { event_id } + | ProjectionEffect::RotateGroupKeys { event_id, .. } + | ProjectionEffect::NotifyAccountChanged { event_id } + | ProjectionEffect::NotifyForkDetected { event_id } => event_id, + }; + effect_event_id == event_id && matches_kind(effect.effect()) + }) + .unwrap() + .clone() +} + +fn append_checkpoint( + store: &RedbProviderStore, + bundle: &krikos_identity::ProviderCheckpointBundle, + observed_at: u64, + signer: &ProviderSigner, +) -> InclusionReceipt { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &AllowProviderAdmission).unwrap(), + Timestamp::from_unix_millis(observed_at), + signer, + ) + .unwrap() +} + +fn observe_checkpoint( + store: &RedbProviderStore, + bundle: &krikos_identity::ProviderCheckpointBundle, + observed_at: u64, + signer: &ProviderSigner, +) -> (InclusionReceipt, MerkleConsistencyProof) { + let receipt = append_checkpoint(store, bundle, observed_at, signer); + let proof = store.consistency_proof(1, 1).unwrap(); + (receipt, proof) +} + +#[derive(Clone)] +struct DirectSubsetAuthorizer { + signer_fills: Vec, + calls: Arc, +} + +impl DirectSubsetAuthorizer { + fn new(signer_fills: Vec) -> Self { + Self { + signer_fills, + calls: Arc::new(AtomicUsize::new(0)), + } + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +impl OperationalCheckpointAuthorizer for DirectSubsetAuthorizer { + fn authorize<'a>( + &'a self, + body: &'a krikos_identity::CheckpointBody, + ) -> StoreFuture<'a, SignedCheckpoint> { + self.calls.fetch_add(1, Ordering::SeqCst); + let result = (|| { + let checkpoint_id = body.checkpoint_id()?; + let mut approvals = Vec::with_capacity(self.signer_fills.len()); + for fill in &self.signer_fills { + let signer = SecretKey::from_bytes(&[*fill; 32]); + let descriptor = controller(&signer); + let signing_key = descriptor.signing_key(); + let approval_body = ControllerApprovalBody::checkpoint( + descriptor.id()?, + checkpoint_id, + Extensions::default(), + )?; + let signature = signer.sign(&approval_body.to_canonical_bytes()?); + approvals.push(SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1()?.crypto_suite_id()?, + ControllerKeyId::for_signing_key(&signing_key)?, + AlgorithmSignature::new(1, signature.to_bytes().to_vec())?, + )], + )?); + } + SignedCheckpoint::new( + body.clone(), + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(approvals)?, + )?, + ) + })(); + Box::pin(async move { result }) + } +} + +#[derive(Debug, Clone, Copy)] +struct DirectSubsetRetryContext { + account_id: AccountId, + effect_id: EffectId, +} + +fn prepare_direct_subset_checkpoint_crash( + account_store: &A, + journal: &OperationalEffectJournal, +) -> DirectSubsetRetryContext +where + A: AccountStore + ?Sized, + J: OperationalEffectStore, +{ + let first = SecretKey::from_bytes(&[0x91; 32]); + let second = SecretKey::from_bytes(&[0x92; 32]); + let third = SecretKey::from_bytes(&[0x93; 32]); + let control_policy = ControlPolicy::new( + vec![ + rule(OperationKind::AddController, false), + rule(OperationKind::ChangeProviderPolicy, false), + ], + Extensions::default(), + ) + .unwrap(); + let recovery_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x94; 32], + Timestamp::from_unix_millis(1), + control_policy, + vec![controller(&first), controller(&second)], + recovery_policy, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let account_id = genesis.account_id().unwrap(); + let initial = block_on(account_store.create_account(genesis)).unwrap(); + let event = authorized_event( + initial.state(), + AccountOperation::AddController(controller(&third)), + 0x95, + &first, + ); + let event_id = event.event_id().unwrap(); + let committed = + block_on(account_store.commit_event(initial.revision().clone(), event)).unwrap(); + let claimed = block_on( + account_store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(200), + Timestamp::from_unix_millis(250), + LeaseId::new([0x96; 16]).unwrap(), + 8, + ) + .unwrap(), + ), + ) + .unwrap(); + let publish = effect_for_event(&claimed, event_id, |effect| { + matches!(effect, ProjectionEffect::PublishAccountEvent { .. }) + }); + journal + .begin(&publish, Timestamp::from_unix_millis(201)) + .unwrap(); + let body = build_checkpoint_body( + committed.snapshot().state(), + Timestamp::from_unix_millis(210), + ) + .unwrap(); + journal + .record_checkpoint_draft(publish.id(), body.clone(), Timestamp::from_unix_millis(211)) + .unwrap(); + let first_subset = DirectSubsetAuthorizer::new(vec![0x91]); + let signed = block_on(first_subset.authorize(&body)).unwrap(); + let verified = verify_checkpoint(committed.snapshot().state(), &signed, None).unwrap(); + block_on(account_store.commit_checkpoint(committed.snapshot().revision().clone(), verified)) + .unwrap(); + assert_eq!(first_subset.calls(), 1); + assert!( + journal + .load(publish.id()) + .unwrap() + .unwrap() + .checkpoint() + .is_none() + ); + DirectSubsetRetryContext { + account_id, + effect_id: publish.id(), + } +} + +fn finish_direct_subset_checkpoint_retry( + account_store: &A, + journal: &OperationalEffectJournal, + context: DirectSubsetRetryContext, +) where + A: AccountStore + ?Sized, + J: OperationalEffectStore, +{ + let snapshot = block_on(account_store.load_account(context.account_id)) + .unwrap() + .unwrap(); + let alternate_subset = DirectSubsetAuthorizer::new(vec![0x92]); + let build = OperationalCheckpointBuild::new( + context.effect_id, + &snapshot, + Timestamp::from_unix_millis(210), + None, + Timestamp::from_unix_millis(211), + Timestamp::from_unix_millis(212), + ); + let committed = block_on(build_authorize_and_commit_checkpoint( + account_store, + journal, + &alternate_subset, + build, + )) + .unwrap(); + let approvals = committed + .checkpoint() + .checkpoint() + .authorization() + .controller_approvals() + .unwrap(); + assert_eq!(approvals.as_slice().len(), 2); + assert_eq!(alternate_subset.calls(), 1); + let retained = journal.load(context.effect_id).unwrap().unwrap(); + assert_eq!( + retained + .checkpoint() + .unwrap() + .authorization() + .controller_approvals() + .unwrap() + .as_slice() + .len(), + 2 + ); + + let replayed = block_on(build_authorize_and_commit_checkpoint( + account_store, + journal, + &alternate_subset, + build, + )) + .unwrap(); + assert_eq!( + replayed.checkpoint().checkpoint_id(), + committed.checkpoint().checkpoint_id() + ); + assert_eq!(alternate_subset.calls(), 1); +} + +#[test] +fn memory_checkpoint_retry_merges_an_alternate_sufficient_approval_subset() { + let account_store = MemoryAccountStore::new(); + let operation_store = MemoryOperationalEffectStore::new(); + let context = prepare_direct_subset_checkpoint_crash( + &account_store, + &OperationalEffectJournal::new(operation_store.clone()), + ); + finish_direct_subset_checkpoint_retry( + &account_store, + &OperationalEffectJournal::new(operation_store), + context, + ); +} + +#[test] +fn redb_checkpoint_retry_merges_an_alternate_sufficient_subset_after_reopen() { + let directory = tempfile::tempdir().unwrap(); + let account_path = directory.path().join("alternate-subset-accounts.redb"); + let operation_path = directory.path().join("alternate-subset-operations.redb"); + let context = { + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + prepare_direct_subset_checkpoint_crash( + &account_store, + &OperationalEffectJournal::new(operation_store), + ) + }; + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + finish_direct_subset_checkpoint_retry( + &account_store, + &OperationalEffectJournal::new(operation_store), + context, + ); +} + +#[test] +fn redb_truncated_operational_effect_is_rejected_on_reopen() { + let directory = tempfile::tempdir().unwrap(); + let account_path = directory.path().join("corrupt-effect-accounts.redb"); + let operation_path = directory.path().join("corrupt-effect-operations.redb"); + let context = { + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + prepare_direct_subset_checkpoint_crash( + &account_store, + &OperationalEffectJournal::new(operation_store), + ) + }; + let database = Database::create(&operation_path).unwrap(); + let write = database.begin_write().unwrap(); + { + let mut table = write.open_table(TEST_OPERATION_TABLE).unwrap(); + let value = table + .get(context.effect_id.as_bytes().as_slice()) + .unwrap() + .unwrap(); + let mut bytes = value.value().to_vec(); + drop(value); + bytes.pop().unwrap(); + table + .insert(context.effect_id.as_bytes().as_slice(), bytes.as_slice()) + .unwrap(); + } + write.commit().unwrap(); + drop(database); + assert!(matches!( + RedbOperationalEffectStore::open(&operation_path), + Err(IdentityError::StorageCorruption) + )); +} + +#[test] +fn finalized_recovery_effects_reconcile_across_every_durable_boundary() { + let directory = tempfile::tempdir().unwrap(); + let account_path = directory.path().join("accounts.redb"); + let operation_path = directory.path().join("operations.redb"); + let first_provider_path = directory.path().join("provider-first.redb"); + let second_provider_path = directory.path().join("provider-second.redb"); + let first_provider_secret = SecretKey::from_bytes(&[0x41; 32]); + let second_provider_secret = SecretKey::from_bytes(&[0x42; 32]); + let first_provider = provider(&first_provider_secret); + let second_provider = provider(&second_provider_secret); + let providers = [ + (&first_provider_secret, &first_provider, 0x51), + (&second_provider_secret, &second_provider, 0x52), + ]; + let (genesis, controller_secret) = fixture(&first_provider, &second_provider); + let account_id = genesis.account_id().unwrap(); + let agreement_secret = AgreementSecretKey::from_bytes([0x23; 32]); + let device = device_authorization(&agreement_secret); + let device_id = device.device_id(); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let initial = block_on(account_store.create_account(genesis.clone())).unwrap(); + let authorize_device = authorized_event( + initial.state(), + AccountOperation::AuthorizeDevice(device), + 0x24, + &controller_secret, + ); + let authorized = + block_on(account_store.commit_event(initial.revision().clone(), authorize_device.clone())) + .unwrap(); + let (begin_recovery, recovery_id) = begin_recovery_event( + authorized.snapshot().state(), + &controller_secret, + device_id, + &providers, + ); + let begin_proposal_id = begin_recovery.body().proposal_id().unwrap(); + let pending = block_on(account_store.commit_event( + authorized.snapshot().revision().clone(), + begin_recovery.clone(), + )) + .unwrap(); + assert_eq!( + pending.snapshot().state().lifecycle(), + ProjectionLifecycle::RecoveryPending + ); + drop(account_store); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let pending = block_on(account_store.load_account(account_id)) + .unwrap() + .unwrap(); + assert_eq!( + pending.state().lifecycle(), + ProjectionLifecycle::RecoveryPending + ); + let finalize_recovery = + finalize_recovery_event(pending.state(), recovery_id, begin_proposal_id, &providers); + let final_event_id = finalize_recovery.event_id().unwrap(); + let finalized = + block_on(account_store.commit_event(pending.revision().clone(), finalize_recovery.clone())) + .unwrap(); + assert_eq!( + finalized.snapshot().state().lifecycle(), + ProjectionLifecycle::Active + ); + let final_revision = finalized.snapshot().revision().clone(); + let final_state = finalized.snapshot().state().clone(); + drop(account_store); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let reopened = block_on(account_store.load_account(account_id)) + .unwrap() + .unwrap(); + assert_eq!(reopened.revision(), &final_revision); + assert_eq!(reopened.state(), &final_state); + let first_lease = LeaseId::new([0x61; 16]).unwrap(); + let claimed = block_on( + account_store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(200), + Timestamp::from_unix_millis(250), + first_lease, + 32, + ) + .unwrap(), + ), + ) + .unwrap(); + let rotation_effect = effect_for_event(&claimed, final_event_id, |effect| { + matches!(effect, ProjectionEffect::RotateGroupKeys { .. }) + }); + let publish_effect = effect_for_event(&claimed, final_event_id, |effect| { + matches!(effect, ProjectionEffect::PublishAccountEvent { .. }) + }); + let notification_effect = effect_for_event(&claimed, final_event_id, |effect| { + matches!(effect, ProjectionEffect::NotifyAccountChanged { .. }) + }); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + for effect in [&rotation_effect, &publish_effect, ¬ification_effect] { + journal + .begin(effect, Timestamp::from_unix_millis(201)) + .unwrap(); + } + + let application_id = ApplicationId::new(digest(0x62)); + let group_id = GroupId::new(digest(0x63)); + assert_eq!( + block_on(account_store.authorize_protected_write( + final_revision.clone(), + application_id, + group_id, + )), + Err(IdentityError::ProtectedWritesBlocked) + ); + let distribution = GroupKeyDistributionSnapshot::from_post_state( + &final_state, + application_id, + group_id, + GroupKeyEpoch::new(3), + vec![device_id], + ) + .unwrap(); + let rotation = rotate_group_key_with_rng( + &distribution, + &GroupKey::new([0x64; 32]), + &mut RepeatingRng(0x65), + ) + .unwrap(); + let replay_rotation = rotate_group_key_with_rng( + &distribution, + &GroupKey::new([0x64; 32]), + &mut RepeatingRng(0x65), + ) + .unwrap(); + let stored_rotation = block_on(account_store.commit_group_key_rotation( + rotation_effect.id(), + first_lease, + rotation, + Timestamp::from_unix_millis(202), + )) + .unwrap(); + drop(journal); + drop(operation_store); + drop(account_store); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let replayed_rotation = block_on(account_store.commit_group_key_rotation( + rotation_effect.id(), + first_lease, + replay_rotation, + Timestamp::from_unix_millis(202), + )) + .unwrap(); + assert_eq!(replayed_rotation, stored_rotation); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + journal + .record_rotation_committed( + rotation_effect.id(), + &stored_rotation, + Timestamp::from_unix_millis(203), + ) + .unwrap(); + journal + .record_completed(rotation_effect.id(), Timestamp::from_unix_millis(204)) + .unwrap(); + block_on(account_store.authorize_protected_write( + final_revision.clone(), + application_id, + group_id, + )) + .unwrap(); + + let checkpoint_body = + build_checkpoint_body(&final_state, Timestamp::from_unix_millis(210)).unwrap(); + let checkpoint = SignedCheckpoint::new( + checkpoint_body.clone(), + CheckpointAuthorization::transition_derived(&finalize_recovery).unwrap(), + ) + .unwrap(); + let verified = verify_checkpoint(&final_state, &checkpoint, Some(&finalize_recovery)).unwrap(); + journal + .record_checkpoint_draft( + publish_effect.id(), + checkpoint_body, + Timestamp::from_unix_millis(211), + ) + .unwrap(); + let checkpoint_commit = + block_on(account_store.commit_checkpoint(final_revision.clone(), verified.clone())) + .unwrap(); + drop(journal); + drop(operation_store); + drop(account_store); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let replayed_checkpoint = + block_on(account_store.commit_checkpoint(final_revision.clone(), verified.clone())) + .unwrap(); + assert_eq!( + replayed_checkpoint.checkpoint_id(), + checkpoint_commit.checkpoint_id() + ); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + journal + .record_checkpoint_authorized( + publish_effect.id(), + &verified, + final_state.provider_policy(), + Timestamp::from_unix_millis(212), + ) + .unwrap(); + + let bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + &[authorize_device, begin_recovery, finalize_recovery.clone()], + &checkpoint, + Some(&finalize_recovery), + ) + .unwrap(); + let first_signer = ProviderSigner(first_provider_secret); + let second_signer = ProviderSigner(second_provider_secret); + let first_store = RedbProviderStore::open( + &first_provider_path, + first_provider.clone(), + typed_id::(0x71), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let second_store = RedbProviderStore::open( + &second_provider_path, + second_provider.clone(), + typed_id::(0x72), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let first_publication = append_checkpoint(&first_store, &bundle, 220, &first_signer); + let checkpoint_id = verified.checkpoint_id(); + let mut tracker = PublicationTracker::new( + account_id, + checkpoint_id, + final_state.provider_policy_id(), + final_state.provider_policy(), + ) + .unwrap(); + tracker.mark_authorized(&verified).unwrap(); + tracker + .record_publication(first_publication.clone()) + .unwrap(); + let partial = journal + .record_publications( + publish_effect.id(), + &tracker, + Timestamp::from_unix_millis(221), + ) + .unwrap(); + assert_eq!(partial.phase(), OperationalEffectPhase::Published); + assert_eq!(partial.provider_receipts().len(), 1); + + let transient = EffectFailure::transient(0x73).unwrap(); + block_on(account_store.retry_effect( + account_id, + publish_effect.id(), + first_lease, + Timestamp::from_unix_millis(300), + transient, + )) + .unwrap(); + drop(journal); + drop(operation_store); + drop(account_store); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + journal + .record_failure( + publish_effect.id(), + publish_effect.attempt_count(), + transient, + Timestamp::from_unix_millis(301), + ) + .unwrap(); + let second_lease = LeaseId::new([0x74; 16]).unwrap(); + let retried = block_on( + account_store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(300), + Timestamp::from_unix_millis(400), + second_lease, + 32, + ) + .unwrap(), + ), + ) + .unwrap(); + let retried_publish = effect_for_event(&retried, final_event_id, |effect| { + matches!(effect, ProjectionEffect::PublishAccountEvent { .. }) + }); + let retried_notification = effect_for_event(&retried, final_event_id, |effect| { + matches!(effect, ProjectionEffect::NotifyAccountChanged { .. }) + }); + let resumed = journal + .begin(&retried_publish, Timestamp::from_unix_millis(302)) + .unwrap(); + assert_eq!(resumed.phase(), OperationalEffectPhase::Published); + journal + .begin(&retried_notification, Timestamp::from_unix_millis(302)) + .unwrap(); + + let second_publication = append_checkpoint(&second_store, &bundle, 222, &second_signer); + let mut tracker = PublicationTracker::new( + account_id, + checkpoint_id, + final_state.provider_policy_id(), + final_state.provider_policy(), + ) + .unwrap(); + tracker.mark_authorized(&verified).unwrap(); + tracker + .record_publication(first_publication.clone()) + .unwrap(); + tracker + .record_publication(second_publication.clone()) + .unwrap(); + assert_eq!(tracker.stage(), PublicationStage::Replicated); + let replicated = journal + .record_publications( + publish_effect.id(), + &tracker, + Timestamp::from_unix_millis(303), + ) + .unwrap(); + assert_eq!(replicated.phase(), OperationalEffectPhase::Replicated); + assert_eq!(replicated.provider_receipts().len(), 2); + + let (first_observation, first_proof) = + observe_checkpoint(&first_store, &bundle, 230, &first_signer); + tracker + .record_observation(first_observation.clone(), &first_proof) + .unwrap(); + let one_observation = journal + .record_observation( + publish_effect.id(), + &tracker, + first_observation.clone(), + first_proof.clone(), + Timestamp::from_unix_millis(304), + ) + .unwrap(); + assert_eq!(one_observation.phase(), OperationalEffectPhase::Replicated); + assert_eq!( + one_observation + .provider_receipts() + .iter() + .filter(|receipt| receipt.observation().is_some()) + .count(), + 1 + ); + drop(journal); + drop(operation_store); + + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + let retained = journal.load(publish_effect.id()).unwrap().unwrap(); + assert_eq!(retained.phase(), OperationalEffectPhase::Replicated); + assert_eq!( + retained + .provider_receipts() + .iter() + .filter(|receipt| receipt.observation().is_some()) + .count(), + 1 + ); + let (second_observation, second_proof) = + observe_checkpoint(&second_store, &bundle, 231, &second_signer); + let mut tracker = PublicationTracker::new( + account_id, + checkpoint_id, + final_state.provider_policy_id(), + final_state.provider_policy(), + ) + .unwrap(); + tracker.mark_authorized(&verified).unwrap(); + tracker.record_publication(first_publication).unwrap(); + tracker.record_publication(second_publication).unwrap(); + tracker + .record_observation(first_observation, &first_proof) + .unwrap(); + tracker + .record_observation(second_observation.clone(), &second_proof) + .unwrap(); + let observed = journal + .record_observation( + publish_effect.id(), + &tracker, + second_observation, + second_proof, + Timestamp::from_unix_millis(305), + ) + .unwrap(); + assert_eq!(observed.phase(), OperationalEffectPhase::Observed); + + block_on(account_store.complete_effect( + account_id, + retried_publish.id(), + second_lease, + Timestamp::from_unix_millis(306), + )) + .unwrap(); + drop(journal); + drop(operation_store); + drop(account_store); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + let snapshot = block_on(account_store.load_account(account_id)) + .unwrap() + .unwrap(); + let completed_publish = snapshot + .outbox() + .iter() + .find(|effect| effect.id() == retried_publish.id()) + .unwrap(); + assert_eq!(completed_publish.status(), EffectStatus::Completed); + block_on(complete_ready_effect( + &account_store, + &journal, + completed_publish, + Timestamp::from_unix_millis(307), + )) + .unwrap(); + + let notifier = IdempotentNotifier::default(); + block_on(notifier.notify(&retried_notification)).unwrap(); + journal + .record_peers_notified(retried_notification.id(), Timestamp::from_unix_millis(308)) + .unwrap(); + drop(journal); + drop(operation_store); + + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + block_on(notifier.notify(&retried_notification)).unwrap(); + assert_eq!(notifier.unique_notifications(), 1); + block_on(account_store.complete_effect( + account_id, + retried_notification.id(), + second_lease, + Timestamp::from_unix_millis(309), + )) + .unwrap(); + drop(journal); + drop(operation_store); + drop(account_store); + + let account_store = RedbAccountStore::open(&account_path).unwrap(); + let operation_store = RedbOperationalEffectStore::open(&operation_path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store.clone()); + let snapshot = block_on(account_store.load_account(account_id)) + .unwrap() + .unwrap(); + let completed_notification = snapshot + .outbox() + .iter() + .find(|effect| effect.id() == retried_notification.id()) + .unwrap(); + block_on(complete_ready_effect( + &account_store, + &journal, + completed_notification, + Timestamp::from_unix_millis(310), + )) + .unwrap(); + + for effect_id in [ + rotation_effect.id(), + retried_publish.id(), + retried_notification.id(), + ] { + assert_eq!( + journal.load(effect_id).unwrap().unwrap().phase(), + OperationalEffectPhase::Completed + ); + } + let snapshot = block_on(account_store.load_account(account_id)) + .unwrap() + .unwrap(); + for effect_id in [ + rotation_effect.id(), + retried_publish.id(), + retried_notification.id(), + ] { + assert_eq!( + snapshot + .outbox() + .iter() + .find(|effect| effect.id() == effect_id) + .unwrap() + .status(), + EffectStatus::Completed + ); + } + let metrics = operation_store.metrics().unwrap(); + assert_eq!(metrics.completed(), 3); + assert_eq!(metrics.publication_shortfalls(), 0); +} diff --git a/protocols/krikos-identity/tests/policy_authorization.rs b/protocols/krikos-identity/tests/policy_authorization.rs new file mode 100644 index 00000000000..4b19941676a --- /dev/null +++ b/protocols/krikos-identity/tests/policy_authorization.rs @@ -0,0 +1,1112 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountId, AccountOperation, AccountState, AdmissionEvidence, + AlgorithmSignature, CanonicalWire, CheckpointId, ControlPolicy, ControllerApprovalBody, + ControllerApprovals, ControllerClass, ControllerDescriptor, ControllerKeyId, ControllerScope, + ControllerSelector, ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, + DelayEvidence, Digest, DurationMillis, Epoch, EventBody, EventId, EventIntentApprovalBody, + EventIntentApprovals, EventPredecessors, Extensions, FreshnessEvidence, FreshnessRequirement, + HashAlgorithm, IdentityError, InclusionReceipt, KeyedSignature, OperationKind, PolicyRule, + ProtocolSignature, ProviderDescriptor, ProviderFreshness, ProviderHeadBody, ProviderKeyVersion, + ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, ProviderPolicy, ProviderPolicyId, + ProviderPolicyVersion, ProviderQuorum, ProviderReceipts, RecoveryAuthority, RecoveryPolicy, + RecoveryPolicyVersion, RequiredWeight, Sequence, SignedControllerApproval, + SignedEventIntentApproval, SignedProviderHead, SigningPublicKey, Timestamp, + verify_event_intent_admission, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn descriptor(secret: &SecretKey, weight: u32, scope: ControllerScope) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(weight).unwrap(), + scope, + Extensions::default(), + ) + .unwrap() +} + +fn state(required_weight: u32) -> (AccountState, SecretKey, SecretKey) { + let first = SecretKey::from_bytes(&[11; 32]); + let second = SecretKey::from_bytes(&[12; 32]); + let controllers = vec![ + descriptor(&first, 1, ControllerScope::all_v1_operations()), + descriptor(&second, 1, ControllerScope::all_v1_operations()), + ]; + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(required_weight).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [2; 32], + Timestamp::from_unix_millis(1), + policy, + controllers, + recovery, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap(); + (AccountState::from_genesis(&genesis).unwrap(), first, second) +} + +fn delayed_state() -> ( + AccountState, + SecretKey, + SecretKey, + SecretKey, + ProviderDescriptor, +) { + delayed_state_with_freshness(FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(1).unwrap(), DurationMillis::new(100)).unwrap(), + )) +} + +fn delayed_state_with_freshness( + rule_freshness: FreshnessRequirement, +) -> ( + AccountState, + SecretKey, + SecretKey, + SecretKey, + ProviderDescriptor, +) { + let first = SecretKey::from_bytes(&[21; 32]); + let second = SecretKey::from_bytes(&[22; 32]); + let provider_secret = SecretKey::from_bytes(&[23; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(2).unwrap(), + ControllerSelector::any_active(), + rule_freshness, + Some(DurationMillis::new(10)), + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [3; 32], + Timestamp::from_unix_millis(1), + policy, + vec![ + descriptor(&first, 1, ControllerScope::all_v1_operations()), + descriptor(&second, 1, ControllerScope::all_v1_operations()), + ], + recovery, + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + ( + AccountState::from_genesis(&genesis).unwrap(), + first, + second, + provider_secret, + provider, + ) +} + +fn replicated_freshness_state( + account_quorum: u16, +) -> ( + AccountState, + SecretKey, + SecretKey, + ProviderDescriptor, + SecretKey, + ProviderDescriptor, +) { + let controller_secret = SecretKey::from_bytes(&[31; 32]); + let first_provider_secret = SecretKey::from_bytes(&[32; 32]); + let second_provider_secret = SecretKey::from_bytes(&[33; 32]); + let first_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*first_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let second_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*second_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::provider_quorum( + ProviderFreshness::new( + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(100), + ) + .unwrap(), + ), + None, + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [4; 32], + Timestamp::from_unix_millis(1), + policy, + vec![descriptor( + &controller_secret, + 1, + ControllerScope::all_v1_operations(), + )], + recovery, + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![first_provider.clone(), second_provider.clone()], + ProviderQuorum::new(account_quorum).unwrap(), + ProviderQuorum::new(2).unwrap(), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + ( + AccountState::from_genesis(&genesis).unwrap(), + controller_secret, + first_provider_secret, + first_provider, + second_provider_secret, + second_provider, + ) +} + +fn provider_receipt( + provider: &ProviderDescriptor, + provider_secret: &SecretKey, + state: &AccountState, + subject: ProviderLogSubject, + observed_at: u64, + fill: u8, +) -> InclusionReceipt { + provider_receipt_with_head_time( + provider, + provider_secret, + state, + subject, + observed_at, + observed_at, + fill, + ) +} + +fn provider_receipt_with_head_time( + provider: &ProviderDescriptor, + provider_secret: &SecretKey, + state: &AccountState, + subject: ProviderLogSubject, + entry_observed_at: u64, + head_observed_at: u64, + fill: u8, +) -> InclusionReceipt { + let log_id = typed_id::(fill); + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + log_id, + state.account_id(), + subject, + Timestamp::from_unix_millis(entry_observed_at), + Extensions::default(), + ) + .unwrap(); + let leaf_root = entry.merkle_leaf_hash().unwrap(); + let head = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + 1, + leaf_root, + Timestamp::from_unix_millis(head_observed_at), + Extensions::default(), + ) + .unwrap(); + let signature = provider_secret.sign(&head.signing_bytes().unwrap()); + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(head, ProtocolSignature::ed25519(signature.to_bytes())), + ) + .unwrap() +} + +fn signed_intent( + state: &AccountState, + proposal_id: krikos_identity::ProposalId, + controller_secret: &SecretKey, + signing_secret: &SecretKey, +) -> SignedEventIntentApproval { + let signing_key = SigningPublicKey::ed25519(*controller_secret.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let body = + EventIntentApprovalBody::new(controller_id, proposal_id, Extensions::default()).unwrap(); + let signature = signing_secret.sign(&body.to_canonical_bytes().unwrap()); + SignedEventIntentApproval::new( + body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap() +} + +fn delayed_event( + state: &AccountState, + final_signers: &[&SecretKey], + intents: Vec, + provider: &ProviderDescriptor, + provider_secret: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let body = unsigned_body(state); + let checkpoint_id = typed_id::(0x57); + let proposal_id = body.proposal_id().unwrap(); + let intent_receipts = ProviderReceipts::new(vec![provider_receipt( + provider, + provider_secret, + state, + ProviderLogSubject::EventIntent(proposal_id), + 10, + 0x71, + )]) + .unwrap(); + let completion_receipts = ProviderReceipts::new(vec![provider_receipt_with_head_time( + provider, + provider_secret, + state, + ProviderLogSubject::Checkpoint(checkpoint_id), + 5, + 20, + 0x72, + )]) + .unwrap(); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::provider_quorum( + checkpoint_id, + state.provider_policy_id(), + completion_receipts, + ) + .unwrap(), + DelayEvidence::provider_quorum( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + EventIntentApprovals::new(intents).unwrap(), + intent_receipts, + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + authorized_event_with_evidence(state, body, evidence, final_signers) +} + +fn event_with_provider_freshness( + state: &AccountState, + signer: &SecretKey, + checkpoint_id: CheckpointId, + receipts: ProviderReceipts, +) -> krikos_identity::AuthorizedEvent { + let body = unsigned_body(state); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::provider_quorum(checkpoint_id, state.provider_policy_id(), receipts) + .unwrap(), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + authorized_event_with_evidence(state, body, evidence, &[signer]) +} + +fn unsigned_body(state: &AccountState) -> EventBody { + EventBody::new( + state.account_id(), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(state.genesis_anchor()), + AccountOperation::AddController(descriptor( + &SecretKey::from_bytes(&[13; 32]), + 1, + ControllerScope::all_v1_operations(), + )), + Timestamp::from_unix_millis(2), + [9; 16], + Extensions::default(), + ) + .unwrap() +} + +fn signed_event( + state: &AccountState, + body: EventBody, + signers: &[&SecretKey], +) -> krikos_identity::AuthorizedEvent { + signed_event_with_provider_policy(state, body, signers, state.provider_policy_id()) +} + +fn signed_event_with_provider_policy( + state: &AccountState, + body: EventBody, + signers: &[&SecretKey], + provider_policy_id: ProviderPolicyId, +) -> krikos_identity::AuthorizedEvent { + signed_event_with_checkpoint(state, body, signers, provider_policy_id, 0x55) +} + +fn signed_event_with_checkpoint( + state: &AccountState, + body: EventBody, + signers: &[&SecretKey], + provider_policy_id: ProviderPolicyId, + checkpoint_fill: u8, +) -> krikos_identity::AuthorizedEvent { + let checkpoint = typed_id::(checkpoint_fill); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint, + provider_policy_id, + FreshnessEvidence::local_known(checkpoint), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + authorized_event_with_evidence(state, body, evidence, signers) +} + +fn authorized_event_with_evidence( + state: &AccountState, + body: EventBody, + evidence: AdmissionEvidence, + signers: &[&SecretKey], +) -> krikos_identity::AuthorizedEvent { + let event_id = evidence.event_id_for_body(&body).unwrap(); + let evidence_id = evidence.admission_evidence_id().unwrap(); + let suite_id = CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(); + let approvals = signers + .iter() + .map(|secret| { + let signing_key = SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + event_id, + evidence_id, + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&approval_body.to_canonical_bytes().unwrap()); + SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + suite_id, + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap() + }) + .collect::>(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(approvals).unwrap(), + ) + .unwrap() +} + +#[test] +fn sequence_predecessor_account_and_evidence_policy_are_bound_without_mutation() { + let (mut projected, first, _) = state(1); + let before = projected.clone(); + let operation = || { + AccountOperation::AddController(descriptor( + &SecretKey::from_bytes(&[13; 32]), + 1, + ControllerScope::all_v1_operations(), + )) + }; + + let skipped_sequence = EventBody::new( + projected.account_id(), + Sequence::new(2), + Epoch::new(1), + EventPredecessors::events(vec![typed_id::(0x61)]).unwrap(), + operation(), + Timestamp::from_unix_millis(2), + [0x61; 16], + Extensions::default(), + ) + .unwrap(); + assert_eq!( + projected.validate_and_apply(&signed_event(&projected, skipped_sequence, &[&first])), + Err(IdentityError::InvalidSequence) + ); + assert_eq!(projected, before); + + let wrong_predecessor = EventBody::new( + projected.account_id(), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id(0x62)), + operation(), + Timestamp::from_unix_millis(2), + [0x62; 16], + Extensions::default(), + ) + .unwrap(); + assert_eq!( + projected.validate_and_apply(&signed_event(&projected, wrong_predecessor, &[&first])), + Err(IdentityError::InvalidPredecessor) + ); + assert_eq!(projected, before); + + let wrong_account = EventBody::new( + typed_id::(0x63), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(projected.genesis_anchor()), + operation(), + Timestamp::from_unix_millis(2), + [0x63; 16], + Extensions::default(), + ) + .unwrap(); + assert_eq!( + projected.validate_and_apply(&signed_event(&projected, wrong_account, &[&first])), + Err(IdentityError::AccountMismatch) + ); + assert_eq!(projected, before); + + let wrong_policy = signed_event_with_provider_policy( + &projected, + unsigned_body(&projected), + &[&first], + typed_id(0x64), + ); + assert_eq!( + projected.validate_and_apply(&wrong_policy), + Err(IdentityError::PolicyVersionMismatch) + ); + assert_eq!(projected, before); +} + +#[test] +fn delayed_intent_uses_the_same_pre_state_threshold_and_exact_key_binding() { + let (base, first, second, provider_secret, provider) = delayed_state(); + let proposal_id = unsigned_body(&base).proposal_id().unwrap(); + + let mut valid_state = base.clone(); + let valid = delayed_event( + &valid_state, + &[&first, &second], + vec![ + signed_intent(&valid_state, proposal_id, &first, &first), + signed_intent(&valid_state, proposal_id, &second, &second), + ], + &provider, + &provider_secret, + ); + valid_state.validate_and_apply(&valid).unwrap(); + assert_eq!(valid_state.active_controllers().len(), 3); + + let mut insufficient_state = base.clone(); + let insufficient = delayed_event( + &insufficient_state, + &[&first, &second], + vec![signed_intent( + &insufficient_state, + proposal_id, + &first, + &first, + )], + &provider, + &provider_secret, + ); + let before = insufficient_state.clone(); + assert_eq!( + insufficient_state.validate_and_apply(&insufficient), + Err(IdentityError::AuthorizationDenied) + ); + assert_eq!(insufficient_state, before); + + let mut forged_state = base; + let forged = delayed_event( + &forged_state, + &[&first, &second], + vec![ + signed_intent(&forged_state, proposal_id, &first, &second), + signed_intent(&forged_state, proposal_id, &second, &second), + ], + &provider, + &provider_secret, + ); + let before = forged_state.clone(); + assert_eq!( + forged_state.validate_and_apply(&forged), + Err(IdentityError::InvalidSignature) + ); + assert_eq!(forged_state, before); +} + +#[test] +fn provider_intent_admission_is_opaque_and_bound_to_the_exact_delayed_body() { + let (base, first, second, _, _) = delayed_state(); + let body = unsigned_body(&base); + let proposal_id = body.proposal_id().unwrap(); + let approvals = EventIntentApprovals::new(vec![ + signed_intent(&base, proposal_id, &first, &first), + signed_intent(&base, proposal_id, &second, &second), + ]) + .unwrap(); + + let admission = verify_event_intent_admission(&base, &body, &approvals).unwrap(); + assert_eq!(admission.account_id(), base.account_id()); + assert_eq!( + admission.subject(), + ProviderLogSubject::EventIntent(proposal_id) + ); + + let insufficient = + EventIntentApprovals::new(vec![signed_intent(&base, proposal_id, &first, &first)]).unwrap(); + assert_eq!( + verify_event_intent_admission(&base, &body, &insufficient), + Err(IdentityError::AuthorizationDenied) + ); + + let wrong_epoch = EventBody::new( + base.account_id(), + Sequence::new(1), + Epoch::GENESIS, + EventPredecessors::genesis(base.genesis_anchor()), + body.operation().clone(), + Timestamp::from_unix_millis(2), + [0x75; 16], + Extensions::default(), + ) + .unwrap(); + assert_eq!( + verify_event_intent_admission(&base, &wrong_epoch, &approvals), + Err(IdentityError::InvalidEpoch) + ); + + let (undelayed, undelayed_first, undelayed_second) = state(2); + let undelayed_body = unsigned_body(&undelayed); + let undelayed_proposal = undelayed_body.proposal_id().unwrap(); + let undelayed_approvals = EventIntentApprovals::new(vec![ + signed_intent( + &undelayed, + undelayed_proposal, + &undelayed_first, + &undelayed_first, + ), + signed_intent( + &undelayed, + undelayed_proposal, + &undelayed_second, + &undelayed_second, + ), + ]) + .unwrap(); + assert!(matches!( + verify_event_intent_admission(&undelayed, &undelayed_body, &undelayed_approvals), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn latest_known_delayed_rule_derives_completion_from_authenticated_intent_heads() { + let (base, first, second, provider_secret, provider) = + delayed_state_with_freshness(FreshnessRequirement::latest_known()); + let build = |state: &AccountState, head_time: u64, fill: u8| { + let body = unsigned_body(state); + let proposal_id = body.proposal_id().unwrap(); + let checkpoint_id = typed_id::(0x58); + let delay_receipts = ProviderReceipts::new(vec![provider_receipt_with_head_time( + &provider, + &provider_secret, + state, + ProviderLogSubject::EventIntent(proposal_id), + 10, + head_time, + fill, + )]) + .unwrap(); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::provider_quorum( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + EventIntentApprovals::new(vec![ + signed_intent(state, proposal_id, &first, &first), + signed_intent(state, proposal_id, &second, &second), + ]) + .unwrap(), + delay_receipts, + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + authorized_event_with_evidence(state, body, evidence, &[&first, &second]) + }; + + let mut below_boundary = base.clone(); + let before = below_boundary.clone(); + assert_eq!( + below_boundary.validate_and_apply(&build(&below_boundary, 19, 0x79)), + Err(IdentityError::DelayNotElapsed) + ); + assert_eq!(below_boundary, before); + + let mut exact_boundary = base; + exact_boundary + .validate_and_apply(&build(&exact_boundary, 20, 0x7a)) + .unwrap(); +} + +#[test] +fn provider_freshness_uses_monotonic_quorum_and_signed_head_age_boundaries() { + let ( + base, + controller, + first_provider_secret, + first_provider, + second_provider_secret, + second_provider, + ) = replicated_freshness_state(1); + let checkpoint_id = typed_id::(0x73); + let stale = provider_receipt_with_head_time( + &first_provider, + &first_provider_secret, + &base, + ProviderLogSubject::Checkpoint(checkpoint_id), + 10, + 111, + 0x74, + ); + let exact_boundary = provider_receipt_with_head_time( + &second_provider, + &second_provider_secret, + &base, + ProviderLogSubject::Checkpoint(checkpoint_id), + 10, + 110, + 0x75, + ); + let mut with_extra_stale = base.clone(); + let event = event_with_provider_freshness( + &with_extra_stale, + &controller, + checkpoint_id, + ProviderReceipts::new(vec![stale.clone(), exact_boundary]).unwrap(), + ); + with_extra_stale.validate_and_apply(&event).unwrap(); + + let mut only_stale = base.clone(); + let stale_event = event_with_provider_freshness( + &only_stale, + &controller, + checkpoint_id, + ProviderReceipts::new(vec![stale]).unwrap(), + ); + let before = only_stale.clone(); + assert_eq!( + only_stale.validate_and_apply(&stale_event), + Err(IdentityError::StaleEvidence) + ); + assert_eq!(only_stale, before); + + let forged_receipt = provider_receipt_with_head_time( + &first_provider, + &second_provider_secret, + &base, + ProviderLogSubject::Checkpoint(checkpoint_id), + 10, + 110, + 0x7a, + ); + let mut forged_state = base.clone(); + let forged_event = event_with_provider_freshness( + &forged_state, + &controller, + checkpoint_id, + ProviderReceipts::new(vec![forged_receipt]).unwrap(), + ); + let before = forged_state.clone(); + assert_eq!( + forged_state.validate_and_apply(&forged_event), + Err(IdentityError::InvalidSignature) + ); + assert_eq!(forged_state, before); + + let (mut account_requires_two, controller, first_provider_secret, first_provider, _, _) = + replicated_freshness_state(2); + let one_valid = provider_receipt_with_head_time( + &first_provider, + &first_provider_secret, + &account_requires_two, + ProviderLogSubject::Checkpoint(checkpoint_id), + 10, + 110, + 0x76, + ); + let one_provider_event = event_with_provider_freshness( + &account_requires_two, + &controller, + checkpoint_id, + ProviderReceipts::new(vec![one_valid]).unwrap(), + ); + assert_eq!( + account_requires_two.validate_and_apply(&one_provider_event), + Err(IdentityError::FreshnessUnavailable) + ); + + let mut reversed_time = base; + let invalid_time = provider_receipt_with_head_time( + &first_provider, + &first_provider_secret, + &reversed_time, + ProviderLogSubject::Checkpoint(checkpoint_id), + 11, + 10, + 0x77, + ); + let invalid_time_event = event_with_provider_freshness( + &reversed_time, + &controller, + checkpoint_id, + ProviderReceipts::new(vec![invalid_time]).unwrap(), + ); + assert_eq!( + reversed_time.validate_and_apply(&invalid_time_event), + Err(IdentityError::InvalidRelationship { + resource: "provider head observation time" + }) + ); +} + +#[test] +fn weighted_threshold_is_evaluated_from_pre_state() { + let (mut state, first, second) = state(2); + let body = unsigned_body(&state); + let short = signed_event(&state, body.clone(), &[&first]); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&short), + Err(IdentityError::AuthorizationDenied) + ); + assert_eq!(state, before); + + let sufficient = signed_event(&state, body, &[&first, &second]); + state.validate_and_apply(&sufficient).unwrap(); + assert_eq!(state.active_controllers().len(), 3); +} + +#[test] +fn empty_outer_approvals_are_rejected_for_ordinary_operations() { + let (state, first, _) = state(1); + let valid = signed_event(&state, unsigned_body(&state), &[&first]); + let empty = ControllerApprovals::new(Vec::new()).unwrap(); + assert_eq!( + krikos_identity::AuthorizedEvent::new( + valid.body().clone(), + valid.admission_evidence().clone(), + empty, + ), + Err(IdentityError::InvalidRelationship { + resource: "authorized event controller approval cardinality" + }) + ); +} + +#[test] +fn invalid_signature_and_wrong_key_binding_fail_closed() { + let (mut state, first, second) = state(1); + let body = unsigned_body(&state); + let event = signed_event(&state, body, &[&first]); + let first_key = SigningPublicKey::ed25519(*first.public().as_bytes()).unwrap(); + let first_controller = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == first_key) + .unwrap() + .id(); + let approval_body = ControllerApprovalBody::event( + first_controller, + event.event_id().unwrap(), + event.admission_evidence().admission_evidence_id().unwrap(), + Extensions::default(), + ); + let approval_body = approval_body.unwrap(); + let forged_signature = second.sign(&approval_body.to_canonical_bytes().unwrap()); + let forged = krikos_identity::AuthorizedEvent::new( + event.body().clone(), + event.admission_evidence().clone(), + ControllerApprovals::new(vec![ + SignedControllerApproval::new( + approval_body.clone(), + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&first_key).unwrap(), + AlgorithmSignature::new(1, forged_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(), + ]) + .unwrap(), + ) + .unwrap(); + + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&forged), + Err(IdentityError::InvalidSignature) + ); + assert_eq!(state, before); + + let valid_signature = first.sign(&approval_body.to_canonical_bytes().unwrap()); + let wrong_key_binding = krikos_identity::AuthorizedEvent::new( + event.body().clone(), + event.admission_evidence().clone(), + ControllerApprovals::new(vec![ + SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key( + &SigningPublicKey::ed25519(*second.public().as_bytes()).unwrap(), + ) + .unwrap(), + AlgorithmSignature::new(1, valid_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(), + ]) + .unwrap(), + ) + .unwrap(); + assert_eq!( + state.validate_and_apply(&wrong_key_binding), + Err(IdentityError::InvalidSignature) + ); + assert_eq!(state, before); +} + +#[test] +fn disjoint_valid_signature_subsets_merge_without_creating_a_fork() { + let (mut state, first, second) = state(1); + let body = unsigned_body(&state); + let first_subset = signed_event(&state, body.clone(), &[&first]); + let second_subset = signed_event(&state, body, &[&second]); + assert_eq!( + first_subset.admission_evidence(), + second_subset.admission_evidence() + ); + let event_id = first_subset.event_id().unwrap(); + assert_eq!(second_subset.event_id().unwrap(), event_id); + state.validate_and_apply(&first_subset).unwrap(); + + let merged = state.validate_and_apply(&second_subset).unwrap(); + assert_eq!( + merged.disposition(), + krikos_identity::ApplyDisposition::ApprovalsMerged + ); + assert_eq!(merged.event_id(), event_id); + assert_eq!(state.heads(), [event_id]); + assert_eq!( + state.lifecycle(), + krikos_identity::ProjectionLifecycle::Active + ); + assert_eq!( + state + .validate_and_apply(&first_subset) + .unwrap() + .disposition(), + krikos_identity::ApplyDisposition::Replay + ); +} + +#[test] +fn distinct_valid_admission_envelopes_converge_to_one_detectable_fork() { + let (base, first, _) = state(1); + let body = unsigned_body(&base); + let first_envelope = signed_event_with_checkpoint( + &base, + body.clone(), + &[&first], + base.provider_policy_id(), + 0x31, + ); + let second_envelope = + signed_event_with_checkpoint(&base, body, &[&first], base.provider_policy_id(), 0x32); + assert_ne!( + first_envelope.admission_evidence(), + second_envelope.admission_evidence() + ); + assert_ne!( + first_envelope.event_id().unwrap(), + second_envelope.event_id().unwrap() + ); + + let mut left = base.clone(); + left.validate_and_apply(&first_envelope).unwrap(); + assert_eq!( + left.validate_and_apply(&second_envelope) + .unwrap() + .disposition(), + krikos_identity::ApplyDisposition::ForkDetected + ); + + let mut right = base; + right.validate_and_apply(&second_envelope).unwrap(); + assert_eq!( + right + .validate_and_apply(&first_envelope) + .unwrap() + .disposition(), + krikos_identity::ApplyDisposition::ForkDetected + ); + + assert_eq!(left, right); + assert_eq!( + left.lifecycle(), + krikos_identity::ProjectionLifecycle::Forked + ); + let mut expected_heads = vec![ + first_envelope.event_id().unwrap(), + second_envelope.event_id().unwrap(), + ]; + expected_heads.sort_unstable(); + assert_eq!(left.heads(), expected_heads); +} + +#[test] +fn missing_policy_rule_is_default_deny() { + let (state, first, _) = state(1); + let body = EventBody::new( + state.account_id(), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(state.genesis_anchor()), + AccountOperation::RemoveController(state.active_controllers()[1].id()), + Timestamp::from_unix_millis(2), + [10; 16], + Extensions::default(), + ) + .unwrap(); + let event = signed_event(&state, body, &[&first]); + let mut projected = state; + assert_eq!( + projected.validate_and_apply(&event), + Err(IdentityError::AuthorizationDenied) + ); +} diff --git a/protocols/krikos-identity/tests/policy_schema.rs b/protocols/krikos-identity/tests/policy_schema.rs new file mode 100644 index 00000000000..13585ea459a --- /dev/null +++ b/protocols/krikos-identity/tests/policy_schema.rs @@ -0,0 +1,484 @@ +use krikos_identity::{ + AgreementPublicKey, CanonicalWire, ControlPolicy, ControllerClass, ControllerDescriptor, + ControllerScope, ControllerSelector, ControllerThreshold, ControllerWeight, Digest, + DurationMillis, EndpointPublicKey, Extensions, FreshnessRequirement, GuardianSetRoot, + GuardianThreshold, HashAlgorithm, IdentityError, OperationKind, PolicyRule, ProtocolVersion, + ProviderDescriptor, ProviderPolicy, ProviderPolicyVersion, ProviderQuorum, + ProviderRotationRule, RecoveryAuthority, RecoveryPolicy, RecoveryPolicyVersion, RequiredWeight, + SigningPublicKey, limits::MAX_TRANSPARENCY_PROVIDERS, +}; + +const SIGNING_KEY_1: [u8; 32] = [ + 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a, + 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a, +]; +const SIGNING_KEY_2: [u8; 32] = [ + 0x3d, 0x40, 0x17, 0xc3, 0xe8, 0x43, 0x89, 0x5a, 0x92, 0xb7, 0x0a, 0xa7, 0x4d, 0x1b, 0x7e, 0xbc, + 0x9c, 0x98, 0x2c, 0xcf, 0x2e, 0xc4, 0x96, 0x8c, 0xc0, 0xcd, 0x55, 0xf1, 0x2a, 0xf4, 0x66, 0x0c, +]; +const SIGNING_KEY_3: [u8; 32] = [ + 0xfc, 0x51, 0xcd, 0x8e, 0x62, 0x18, 0xa1, 0xa3, 0x8d, 0xa4, 0x7e, 0xd0, 0x02, 0x30, 0xf0, 0x58, + 0x08, 0x16, 0xed, 0x13, 0xba, 0x33, 0x03, 0xac, 0x5d, 0xeb, 0x91, 0x15, 0x48, 0x90, 0x80, 0x25, +]; + +fn signing_key(bytes: [u8; 32]) -> SigningPublicKey { + SigningPublicKey::ed25519(bytes).unwrap() +} + +fn controller(weight: u32) -> ControllerDescriptor { + ControllerDescriptor::new( + signing_key(SIGNING_KEY_1), + ControllerClass::PersonalDevice, + ControllerWeight::new(weight).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn rule(required_weight: u32) -> PolicyRule { + PolicyRule::new( + OperationKind::ChangeControlPolicy, + RequiredWeight::new(required_weight).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap() +} + +#[test] +fn controller_class_and_scope_codepoints_are_frozen() { + assert_eq!(ControllerClass::PersonalDevice.code(), 1); + assert_eq!(ControllerClass::HardwareSecurityKey.code(), 2); + assert_eq!(ControllerClass::OfflineRecovery.code(), 3); + assert_eq!(ControllerClass::GuardianAccount.code(), 4); + assert_eq!(ControllerClass::Institutional.code(), 5); + + let scope = ControllerScope::operations(vec![ + OperationKind::RevokeDevice, + OperationKind::AuthorizeDevice, + ]) + .unwrap(); + assert_eq!( + scope.as_operations().unwrap(), + [OperationKind::AuthorizeDevice, OperationKind::RevokeDevice] + ); + assert!(matches!( + ControllerScope::operations(vec![ + OperationKind::AuthorizeDevice, + OperationKind::AuthorizeDevice, + ]), + Err(IdentityError::DuplicateElement { .. }) + )); + assert_eq!( + ControllerScope::all_v1_operations() + .to_canonical_bytes() + .unwrap(), + [1, 0] + ); + assert_eq!(scope.to_canonical_bytes().unwrap(), [2, 2, 1, 6]); +} + +#[test] +fn unsorted_operation_scope_wire_is_rejected_not_normalized() { + let unsorted = postcard::to_stdvec(&( + 2_u16, + vec![OperationKind::RevokeDevice, OperationKind::AuthorizeDevice], + )) + .unwrap(); + assert!(ControllerScope::from_canonical_bytes(&unsorted).is_err()); +} + +#[test] +fn unsorted_selector_rules_and_providers_are_rejected_not_normalized() { + let second_controller = ControllerDescriptor::new( + signing_key(SIGNING_KEY_2), + ControllerClass::HardwareSecurityKey, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap(); + let mut identifiers = vec![controller(1).id().unwrap(), second_controller.id().unwrap()]; + identifiers.sort_unstable(); + identifiers.reverse(); + let unsorted_selector = postcard::to_stdvec(&( + 2_u16, + Some(identifiers), + Option::>::None, + )) + .unwrap(); + assert!(ControllerSelector::from_canonical_bytes(&unsorted_selector).is_err()); + + let resolve_rule = PolicyRule::new( + OperationKind::ResolveFork, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(); + let unsorted_policy = postcard::to_stdvec(&( + ProtocolVersion::V1, + vec![resolve_rule, rule(1)], + true, + Extensions::default(), + )) + .unwrap(); + assert!(ControlPolicy::from_canonical_bytes(&unsorted_policy).is_err()); + + let providers = [SIGNING_KEY_1, SIGNING_KEY_2, SIGNING_KEY_3] + .into_iter() + .map(|key| ProviderDescriptor::new(signing_key(key), Extensions::default()).unwrap()) + .collect::>(); + let canonical = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + providers, + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(3).unwrap(), + DurationMillis::new(60_000), + Extensions::default(), + ) + .unwrap(); + let mut reversed = canonical.providers().unwrap().to_vec(); + reversed.reverse(); + let replicated_payload = ( + reversed, + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(3).unwrap(), + DurationMillis::new(60_000), + ProviderRotationRule::AccountEventOnly, + ); + let unsorted_provider_policy = postcard::to_stdvec(&( + ProtocolVersion::V1, + ProviderPolicyVersion::GENESIS, + (2_u16, Some(replicated_payload)), + Extensions::default(), + )) + .unwrap(); + assert!(ProviderPolicy::from_canonical_bytes(&unsorted_provider_policy).is_err()); +} + +#[test] +fn controller_provider_and_device_ids_are_stable() { + let descriptor = controller(2); + let id = descriptor.id().unwrap(); + let decoded = + ControllerDescriptor::from_canonical_bytes(&descriptor.to_canonical_bytes().unwrap()) + .unwrap(); + assert_eq!(decoded.id().unwrap(), id); + + let provider = + ProviderDescriptor::new(signing_key(SIGNING_KEY_2), Extensions::default()).unwrap(); + assert_eq!( + ProviderDescriptor::from_canonical_bytes(&provider.to_canonical_bytes().unwrap()) + .unwrap() + .id() + .unwrap(), + provider.id().unwrap() + ); + + let mut agreement_bytes = [0; 32]; + agreement_bytes[0] = 9; + let device = krikos_identity::DeviceDescriptor::new( + signing_key(SIGNING_KEY_1), + AgreementPublicKey::x25519(agreement_bytes).unwrap(), + EndpointPublicKey::new(signing_key(SIGNING_KEY_2)), + Extensions::default(), + ) + .unwrap(); + assert_eq!( + krikos_identity::DeviceDescriptor::from_canonical_bytes( + &device.to_canonical_bytes().unwrap() + ) + .unwrap() + .id() + .unwrap(), + device.id().unwrap() + ); + + assert!(matches!( + krikos_identity::DeviceDescriptor::new( + signing_key(SIGNING_KEY_1), + AgreementPublicKey::x25519(agreement_bytes).unwrap(), + EndpointPublicKey::new(signing_key(SIGNING_KEY_1)), + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn control_policy_sorts_rules_and_validates_weight() { + let second = PolicyRule::new( + OperationKind::ResolveFork, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(); + let policy = ControlPolicy::new(vec![second, rule(2)], Extensions::default()).unwrap(); + assert_eq!( + policy.rules()[0].operation(), + OperationKind::ChangeControlPolicy + ); + assert!(policy.default_deny()); + policy.validate_satisfiable(&[controller(2)]).unwrap(); + assert!(matches!( + policy.validate_satisfiable(&[controller(1)]), + Err(IdentityError::UnsatisfiableThreshold) + )); + + assert!(matches!( + ControlPolicy::new(vec![rule(1), rule(1)], Extensions::default()), + Err(IdentityError::DuplicateElement { .. }) + )); +} + +#[test] +fn duplicate_active_controller_key_cannot_multiply_weight() { + let first = controller(1); + let second = ControllerDescriptor::new( + signing_key(SIGNING_KEY_1), + ControllerClass::HardwareSecurityKey, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap(); + let policy = ControlPolicy::new(vec![rule(1)], Extensions::default()).unwrap(); + assert!(matches!( + policy.validate_satisfiable(&[first, second]), + Err(IdentityError::DuplicateSigningKey) + )); +} + +#[test] +fn replicated_provider_policy_enforces_thresholds() { + let providers = [SIGNING_KEY_1, SIGNING_KEY_2, SIGNING_KEY_3] + .into_iter() + .map(|key| ProviderDescriptor::new(signing_key(key), Extensions::default()).unwrap()) + .collect::>(); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + providers.clone(), + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(3).unwrap(), + DurationMillis::new(60_000), + Extensions::default(), + ) + .unwrap(); + assert_eq!(policy.providers().unwrap().len(), 3); + assert_eq!( + ProviderPolicy::from_canonical_bytes(&policy.to_canonical_bytes().unwrap()) + .unwrap() + .id() + .unwrap(), + policy.id().unwrap() + ); + + assert!(matches!( + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![providers[0].clone(); MAX_TRANSPARENCY_PROVIDERS + 1], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(60_000), + Extensions::default(), + ), + Err(IdentityError::LimitExceeded { + resource: "provider policy providers", + actual, + maximum: MAX_TRANSPARENCY_PROVIDERS, + }) if actual == MAX_TRANSPARENCY_PROVIDERS + 1 + )); + + assert!(matches!( + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + providers, + ProviderQuorum::new(3).unwrap(), + ProviderQuorum::new(2).unwrap(), + DurationMillis::new(60_000), + Extensions::default(), + ), + Err(IdentityError::InvalidPolicy { .. }) + )); + + let duplicate_key = vec![ + ProviderDescriptor::new(signing_key(SIGNING_KEY_1), Extensions::default()).unwrap(), + ProviderDescriptor::new( + signing_key(SIGNING_KEY_1), + Extensions::new(vec![ + krikos_identity::Extension::new(7, false, vec![1]).unwrap(), + ]) + .unwrap(), + ) + .unwrap(), + ]; + assert!(matches!( + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + duplicate_key, + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(2).unwrap(), + DurationMillis::new(60_000), + Extensions::default(), + ), + Err(IdentityError::DuplicateSigningKey) + )); + + assert!( + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![ + ProviderDescriptor::new(signing_key(SIGNING_KEY_1), Extensions::default()).unwrap() + ], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(0), + Extensions::default(), + ) + .is_err() + ); +} + +#[test] +fn recovery_policy_supports_controller_or_private_guardian_authority() { + let controller_authority = RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(2).unwrap(), + )); + let controller_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + controller_authority, + DurationMillis::new(10_000), + DurationMillis::new(60_000), + Extensions::default(), + ) + .unwrap(); + assert_eq!( + RecoveryPolicy::from_canonical_bytes(&controller_policy.to_canonical_bytes().unwrap()) + .unwrap() + .id() + .unwrap(), + controller_policy.id().unwrap() + ); + + let guardians = GuardianThreshold::new( + GuardianSetRoot::new(Digest::new(HashAlgorithm::Blake3_256, [7; 32])).unwrap(), + 3, + 3, + RequiredWeight::new(2).unwrap(), + ) + .unwrap(); + RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::guardian_threshold(guardians), + DurationMillis::new(10_000), + DurationMillis::new(60_000), + Extensions::default(), + ) + .unwrap(); + + assert!( + GuardianThreshold::new( + GuardianSetRoot::new(Digest::new(HashAlgorithm::Blake3_256, [8; 32])).unwrap(), + 3, + 3, + RequiredWeight::new(4).unwrap(), + ) + .is_err() + ); + assert!( + GuardianThreshold::new( + GuardianSetRoot::new(Digest::new(HashAlgorithm::Blake3_256, [8; 32])).unwrap(), + 17, + 17, + RequiredWeight::new(1).unwrap(), + ) + .is_err() + ); + + assert!( + RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(0), + DurationMillis::new(60_000), + Extensions::default(), + ) + .is_err() + ); + assert!( + RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(60_000), + DurationMillis::new(60_000), + Extensions::default(), + ) + .is_err() + ); +} + +#[test] +fn recovery_control_rules_are_gates_not_duplicate_authorization_policies() { + let nonexistent = controller(1).id().unwrap(); + let recovery_rule = PolicyRule::new( + OperationKind::BeginRecovery, + RequiredWeight::new(u32::MAX).unwrap(), + ControllerSelector::controller_ids(vec![nonexistent]).unwrap(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(); + let policy = ControlPolicy::new(vec![recovery_rule], Extensions::default()).unwrap(); + let active = ControllerDescriptor::new( + signing_key(SIGNING_KEY_2), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::operations(vec![ + OperationKind::BeginRecovery, + OperationKind::CancelRecovery, + ]) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + + policy.validate_satisfiable(&[active]).unwrap(); +} + +#[test] +fn controller_recovery_threshold_must_cover_begin_and_cancel_scopes() { + let begin_only = ControllerDescriptor::new( + signing_key(SIGNING_KEY_1), + ControllerClass::OfflineRecovery, + ControllerWeight::new(1).unwrap(), + ControllerScope::operations(vec![OperationKind::BeginRecovery]).unwrap(), + Extensions::default(), + ) + .unwrap(); + let threshold = ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + ); + + assert_eq!( + threshold.validate_satisfiable(&[begin_only]), + Err(IdentityError::UnsatisfiableThreshold) + ); +} diff --git a/protocols/krikos-identity/tests/presence.rs b/protocols/krikos-identity/tests/presence.rs new file mode 100644 index 00000000000..6711295c8af --- /dev/null +++ b/protocols/krikos-identity/tests/presence.rs @@ -0,0 +1,420 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, AgreementSecretKey, ApplicationAuthorizationView, ApplicationDeviceStatus, + AuthorizationContext, CanonicalWire, CheckpointId, DeviceAuthorization, DeviceClass, + DeviceDescriptor, DeviceId, DevicePresenceChallenge, Digest, EndpointPublicKey, Epoch, + Extensions, HashAlgorithm, IdentityError, PresenceProof, PresenceSessionId, + PresenceVerifierChallenge, ProtocolSignature, SigningPublicKey, Timestamp, + verify_presence_proof, +}; + +fn typed_id(seed: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [seed; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +struct DeviceSecrets { + application: SecretKey, + agreement: AgreementSecretKey, + endpoint: SecretKey, +} + +impl DeviceSecrets { + fn new(seed: u8) -> Self { + Self { + application: SecretKey::from_bytes(&[seed; 32]), + agreement: AgreementSecretKey::from_bytes([seed.checked_add(1).unwrap(); 32]), + endpoint: SecretKey::from_bytes(&[seed.checked_add(2).unwrap(); 32]), + } + } + + fn descriptor(&self) -> DeviceDescriptor { + DeviceDescriptor::new( + SigningPublicKey::ed25519(*self.application.public().as_bytes()).unwrap(), + self.agreement.public_key().unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*self.endpoint.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap() + } +} + +struct View { + context: AuthorizationContext, + status: ApplicationDeviceStatus, + authorization: DeviceAuthorization, +} + +impl ApplicationAuthorizationView for View { + fn authorization_context(&self) -> AuthorizationContext { + self.context + } + + fn device_status(&self, device_id: DeviceId) -> ApplicationDeviceStatus { + if device_id == self.authorization.device_id() { + self.status + } else { + ApplicationDeviceStatus::Unknown + } + } + + fn device_authorization(&self, device_id: DeviceId) -> Option<&DeviceAuthorization> { + (device_id == self.authorization.device_id()).then_some(&self.authorization) + } +} + +fn fixture() -> (DeviceSecrets, View, DevicePresenceChallenge) { + let secrets = DeviceSecrets::new(10); + let descriptor = secrets.descriptor(); + let device_id = descriptor.id().unwrap(); + let account_id: AccountId = typed_id(1); + let checkpoint_id: CheckpointId = typed_id(2); + let authorization = DeviceAuthorization::new( + device_id, + descriptor.clone(), + DeviceClass::GeneralPurpose, + None, + Vec::new(), + Epoch::new(7), + Extensions::default(), + ) + .unwrap(); + let view = View { + context: AuthorizationContext::new(account_id, Epoch::new(7), checkpoint_id), + status: ApplicationDeviceStatus::Active, + authorization, + }; + let challenge = DevicePresenceChallenge::new( + account_id, + device_id, + PresenceVerifierChallenge::new([0x31; 32]).unwrap(), + PresenceSessionId::new([0x41; 32]).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0x51; 32]), + checkpoint_id, + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(301_000), + descriptor.application_signing_key(), + Extensions::default(), + ) + .unwrap(); + (secrets, view, challenge) +} + +fn signed_proof(secrets: &DeviceSecrets, challenge: DevicePresenceChallenge) -> PresenceProof { + let signature = secrets + .application + .sign(&challenge.signing_bytes().unwrap()); + PresenceProof::new(challenge, ProtocolSignature::ed25519(signature.to_bytes())).unwrap() +} + +#[test] +fn exact_active_known_checkpoint_presence_proof_verifies() { + let (secrets, view, challenge) = fixture(); + let proof = signed_proof(&secrets, challenge.clone()); + let encoded = proof.to_canonical_bytes().unwrap(); + let challenge_bytes = challenge.to_canonical_bytes().unwrap(); + assert_eq!( + hex::encode(&challenge_bytes), + "0101010101010101010101010101010101010101010101010101010101010101010101343b62f7a40db173198b2d5d3ff1df419169d8e27f50f0f7b8845d993abdff0a31313131313131313131313131313131313131313131313131313131313131314141414141414141414141414141414141414141414141414141414141414141015151515151515151515151515151515151515151515151515151515151515151010202020202020202020202020202020202020202020202020202020202020202e807c8af120143a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c00" + ); + let signing_bytes = challenge.signing_bytes().unwrap(); + assert!(signing_bytes.starts_with(b"KRIKOS-ID/device-presence-signature/v1\0")); + assert!(signing_bytes.ends_with(&challenge_bytes)); + assert_eq!( + hex::encode(proof.signature().as_bytes()), + concat!( + "5fb96b07b0867bd83e5674d136f3c6a04344f37589c82033771fef248fd26edb", + "e6ad61318b1f29d9ac87fe047c2ce3ce0c47c8bb009fa6d5277b6bff5ba62601" + ) + ); + assert_eq!( + proof.proof_id().unwrap().as_digest().to_string(), + "b3:0a4f077d3a4092e04871eb868d7a14c3ac0eb6b5fc2852a076efd8320c109b86" + ); + + assert_eq!( + PresenceProof::from_canonical_bytes(&encoded).unwrap(), + proof + ); + assert!( + verify_presence_proof( + &proof, + &challenge, + Timestamp::from_unix_millis(1_000), + &view, + ) + .is_ok() + ); +} + +#[test] +fn challenge_session_transcript_checkpoint_and_account_substitution_fail() { + let (secrets, view, challenge) = fixture(); + let proof = signed_proof(&secrets, challenge.clone()); + let variants = [ + DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + PresenceVerifierChallenge::new([0x32; 32]).unwrap(), + challenge.session_id(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + challenge.issued_at(), + challenge.expires_at(), + challenge.signing_key(), + Extensions::default(), + ) + .unwrap(), + DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + challenge.verifier_challenge(), + PresenceSessionId::new([0x42; 32]).unwrap(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + challenge.issued_at(), + challenge.expires_at(), + challenge.signing_key(), + Extensions::default(), + ) + .unwrap(), + DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + challenge.verifier_challenge(), + challenge.session_id(), + Digest::new(HashAlgorithm::Blake3_256, [0x52; 32]), + challenge.checkpoint_id(), + challenge.issued_at(), + challenge.expires_at(), + challenge.signing_key(), + Extensions::default(), + ) + .unwrap(), + DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + challenge.verifier_challenge(), + challenge.session_id(), + challenge.transcript_binding(), + typed_id(3), + challenge.issued_at(), + challenge.expires_at(), + challenge.signing_key(), + Extensions::default(), + ) + .unwrap(), + DevicePresenceChallenge::new( + typed_id(4), + challenge.device_id(), + challenge.verifier_challenge(), + challenge.session_id(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + challenge.issued_at(), + challenge.expires_at(), + challenge.signing_key(), + Extensions::default(), + ) + .unwrap(), + ]; + + for substituted in variants { + assert!(matches!( + verify_presence_proof( + &proof, + &substituted, + Timestamp::from_unix_millis(1_000), + &view, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + } +} + +#[test] +fn inactive_or_wrong_exact_device_key_is_rejected() { + let (secrets, mut view, challenge) = fixture(); + let proof = signed_proof(&secrets, challenge.clone()); + view.status = ApplicationDeviceStatus::Suspended; + assert_eq!( + verify_presence_proof( + &proof, + &challenge, + Timestamp::from_unix_millis(1_000), + &view, + ) + .unwrap_err(), + IdentityError::DeviceSuspended + ); + + view.status = ApplicationDeviceStatus::Revoked; + assert_eq!( + verify_presence_proof( + &proof, + &challenge, + Timestamp::from_unix_millis(1_000), + &view, + ) + .unwrap_err(), + IdentityError::DeviceRevoked + ); + + view.status = ApplicationDeviceStatus::Active; + let wrong = DeviceSecrets::new(70); + let wrong_challenge = DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + challenge.verifier_challenge(), + challenge.session_id(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + challenge.issued_at(), + challenge.expires_at(), + wrong.descriptor().application_signing_key(), + Extensions::default(), + ) + .unwrap(); + let wrong_proof = signed_proof(&wrong, wrong_challenge.clone()); + assert!(matches!( + verify_presence_proof( + &wrong_proof, + &wrong_challenge, + Timestamp::from_unix_millis(1_000), + &view, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn forged_signature_and_device_replay_are_rejected() { + let (secrets, view, challenge) = fixture(); + let wrong = DeviceSecrets::new(90); + let signature = wrong.application.sign(&challenge.signing_bytes().unwrap()); + let forged = PresenceProof::new( + challenge.clone(), + ProtocolSignature::ed25519(signature.to_bytes()), + ) + .unwrap(); + assert_eq!( + verify_presence_proof( + &forged, + &challenge, + Timestamp::from_unix_millis(1_000), + &view, + ) + .unwrap_err(), + IdentityError::InvalidSignature + ); + + let proof = signed_proof(&secrets, challenge.clone()); + let other_device = wrong.descriptor().id().unwrap(); + let replay_context = DevicePresenceChallenge::new( + challenge.account_id(), + other_device, + challenge.verifier_challenge(), + challenge.session_id(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + challenge.issued_at(), + challenge.expires_at(), + challenge.signing_key(), + Extensions::default(), + ) + .unwrap(); + assert!(matches!( + verify_presence_proof( + &proof, + &replay_context, + Timestamp::from_unix_millis(1_000), + &view, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn five_minute_lifetime_expiry_and_two_minute_future_skew_edges_are_exact() { + let (secrets, view, challenge) = fixture(); + let proof = signed_proof(&secrets, challenge.clone()); + assert!( + verify_presence_proof( + &proof, + &challenge, + Timestamp::from_unix_millis(301_000), + &view, + ) + .is_ok() + ); + assert_eq!( + verify_presence_proof( + &proof, + &challenge, + Timestamp::from_unix_millis(301_001), + &view, + ) + .unwrap_err(), + IdentityError::StaleEvidence + ); + + let future = DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + challenge.verifier_challenge(), + challenge.session_id(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + Timestamp::from_unix_millis(121_000), + Timestamp::from_unix_millis(421_000), + challenge.signing_key(), + Extensions::default(), + ) + .unwrap(); + let future_proof = signed_proof(&secrets, future.clone()); + assert!( + verify_presence_proof( + &future_proof, + &future, + Timestamp::from_unix_millis(1_000), + &view, + ) + .is_ok() + ); + assert!(matches!( + verify_presence_proof( + &future_proof, + &future, + Timestamp::from_unix_millis(999), + &view, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + assert!( + DevicePresenceChallenge::new( + challenge.account_id(), + challenge.device_id(), + challenge.verifier_challenge(), + challenge.session_id(), + challenge.transcript_binding(), + challenge.checkpoint_id(), + Timestamp::from_unix_millis(1_000), + Timestamp::from_unix_millis(301_001), + challenge.signing_key(), + Extensions::default(), + ) + .is_err() + ); + assert!(matches!( + verify_presence_proof( + &proof, + &challenge, + Timestamp::from_unix_millis(u64::MAX), + &view, + ), + Err(IdentityError::ArithmeticOverflow { .. }) + )); +} diff --git a/protocols/krikos-identity/tests/privacy_boundaries.rs b/protocols/krikos-identity/tests/privacy_boundaries.rs new file mode 100644 index 00000000000..430e5cadbfc --- /dev/null +++ b/protocols/krikos-identity/tests/privacy_boundaries.rs @@ -0,0 +1,574 @@ +use std::{cell::RefCell, convert::Infallible}; + +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, AccountOperation, AdmissionEvidence, AlgorithmSignature, BlindedCommitment, + BlindingSecret, CanonicalSigningRequest, CanonicalWire, CheckpointId, ControllerApprovalBody, + ControllerClass, ControllerDescriptor, ControllerScope, ControllerWeight, CredentialClaim, + CredentialVerificationContext, DelayEvidence, Digest, Epoch, EventBody, EventPredecessors, + Extensions, FreshnessEvidence, HardwareApprovalRequest, HardwareController, HashAlgorithm, + IdentityError, LookupHandleSecret, OfflineSigner, OperationKind, PairwiseIdentifier, + PairwiseMasterSecret, PortableCredentialBody, PrivateCheckpointLookupHandle, PrivateLabel, + ProtocolVersion, ProviderId, ProviderPolicyId, RelyingPartyContext, Sequence, + SignedPortableCredential, SigningPublicKey, SigningPurpose, Timestamp, + verify_portable_credential, +}; +use rand_core::{TryCryptoRng, TryRng}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +#[test] +fn fresh_blinding_hides_equal_low_entropy_labels() { + let label = PrivateLabel::try_new(b"family".to_vec()).unwrap(); + let first = BlindingSecret::generate_with_rng(&mut RepeatingRng(0x11)).unwrap(); + let second = BlindingSecret::generate_with_rng(&mut RepeatingRng(0x12)).unwrap(); + let first_commitment = BlindedCommitment::relationship_label(&label, &first).unwrap(); + let second_commitment = BlindedCommitment::relationship_label(&label, &second).unwrap(); + assert_ne!(first_commitment, second_commitment); + + let guessed = PrivateLabel::try_new(b"family".to_vec()).unwrap(); + let attacker_blinding = BlindingSecret::generate_with_rng(&mut RepeatingRng(0x13)).unwrap(); + assert_ne!( + first_commitment, + BlindedCommitment::relationship_label(&guessed, &attacker_blinding).unwrap() + ); + let encoded = first_commitment.to_canonical_bytes().unwrap(); + assert_eq!( + BlindedCommitment::from_canonical_bytes(&encoded).unwrap(), + first_commitment + ); + assert_eq!( + blake3::hash(&encoded).as_bytes(), + &[ + 0x2f, 0x4e, 0x2b, 0x35, 0x99, 0x48, 0x76, 0x01, 0xf0, 0x34, 0x12, 0xf5, 0x93, 0x81, + 0x80, 0x62, 0x41, 0xb7, 0xe0, 0x70, 0x07, 0xf6, 0x80, 0x59, 0x21, 0x30, 0xb4, 0x00, + 0x37, 0x46, 0xa7, 0x31, + ] + ); + assert_eq!(format!("{label:?}"), "PrivateLabel()"); + assert_eq!(format!("{first:?}"), "BlindingSecret()"); +} + +#[test] +fn lookup_handles_rotate_and_bind_provider_account_and_generation() { + let secret = LookupHandleSecret::try_new([0x21; 32]).unwrap(); + let account = typed_id::(0x22); + let other_account = typed_id::(0x23); + let provider = typed_id::(0x24); + let other_provider = typed_id::(0x25); + + assert!(matches!( + PrivateCheckpointLookupHandle::derive(&secret, provider, account, 0), + Err(IdentityError::ZeroValue { .. }) + )); + + let handle = PrivateCheckpointLookupHandle::derive(&secret, provider, account, 1).unwrap(); + assert_eq!( + handle, + PrivateCheckpointLookupHandle::derive(&secret, provider, account, 1).unwrap() + ); + assert_ne!( + handle, + PrivateCheckpointLookupHandle::derive(&secret, provider, account, 2).unwrap() + ); + assert_ne!( + handle, + PrivateCheckpointLookupHandle::derive(&secret, other_provider, account, 1).unwrap() + ); + assert_ne!( + handle, + PrivateCheckpointLookupHandle::derive(&secret, provider, other_account, 1).unwrap() + ); + assert!( + !handle + .to_canonical_bytes() + .unwrap() + .windows(32) + .any(|window| { window == account.to_canonical_bytes().unwrap().as_slice() }) + ); + let encoded = handle.to_canonical_bytes().unwrap(); + assert_eq!( + PrivateCheckpointLookupHandle::from_canonical_bytes(&encoded).unwrap(), + handle + ); + assert_eq!( + blake3::hash(&encoded).as_bytes(), + &[ + 0x64, 0x2c, 0x1b, 0x8a, 0x59, 0x54, 0xe6, 0xad, 0xfe, 0x6a, 0xde, 0xd8, 0x07, 0x21, + 0x13, 0x55, 0x11, 0x9f, 0x89, 0xc3, 0xf9, 0x6d, 0x2a, 0xcd, 0xf0, 0x61, 0xee, 0xdf, + 0x20, 0x51, 0xb2, 0x1d, + ] + ); +} + +#[test] +fn pairwise_identifiers_normalize_context_and_separate_relying_parties() { + let master = PairwiseMasterSecret::try_new([0x31; 32]).unwrap(); + let account = typed_id::(0x32); + let normalized = RelyingPartyContext::try_new("Login.Example.COM").unwrap(); + assert_eq!(normalized.as_str(), "login.example.com"); + let same = RelyingPartyContext::try_new("login.example.com").unwrap(); + let other = RelyingPartyContext::try_new("payments.example.com").unwrap(); + assert_eq!( + PairwiseIdentifier::derive(&master, account, &normalized).unwrap(), + PairwiseIdentifier::derive(&master, account, &same).unwrap() + ); + assert_ne!( + PairwiseIdentifier::derive(&master, account, &normalized).unwrap(), + PairwiseIdentifier::derive(&master, account, &other).unwrap() + ); + assert_ne!( + PairwiseIdentifier::derive(&master, account, &normalized).unwrap(), + PairwiseIdentifier::derive(&master, typed_id::(0x33), &normalized).unwrap() + ); + for ambiguous in [ + "", + ".example.com", + "example..com", + "-example.com", + "éxample.com", + ] { + assert!(RelyingPartyContext::try_new(ambiguous).is_err()); + } + let identifier = PairwiseIdentifier::derive(&master, account, &normalized).unwrap(); + let encoded = identifier.to_canonical_bytes().unwrap(); + assert_eq!( + PairwiseIdentifier::from_canonical_bytes(&encoded).unwrap(), + identifier + ); + assert_eq!( + blake3::hash(&encoded).as_bytes(), + &[ + 0x79, 0x3b, 0x72, 0x09, 0x7c, 0xe1, 0x50, 0x96, 0x47, 0x75, 0x4a, 0xab, 0x82, 0x46, + 0x48, 0x10, 0xfd, 0x81, 0xbc, 0x42, 0xcf, 0x4f, 0x5f, 0x7b, 0x21, 0x2c, 0x46, 0xe9, + 0xdb, 0x72, 0x16, 0x60, + ] + ); +} + +struct FakeOfflineSigner { + secret: SecretKey, + observed: RefCell>, +} + +impl OfflineSigner for FakeOfflineSigner { + fn sign_exact( + &self, + request: &CanonicalSigningRequest, + ) -> Result { + self.observed.replace(request.canonical_message().to_vec()); + let signature = self.secret.sign(request.canonical_message()); + AlgorithmSignature::new(1, signature.to_bytes().to_vec()) + } +} + +impl HardwareController for FakeOfflineSigner { + fn approve_exact( + &self, + request: &HardwareApprovalRequest, + ) -> Result { + self.observed.replace(request.canonical_message().to_vec()); + let signature = self.secret.sign(request.canonical_message()); + AlgorithmSignature::new(1, signature.to_bytes().to_vec()) + } +} + +fn account_approval_fixture( + signing_key: SigningPublicKey, + nonce: u8, +) -> ( + EventBody, + AdmissionEvidence, + ControllerApprovalBody, + ControllerDescriptor, +) { + let controller = ControllerDescriptor::new( + signing_key, + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap(); + let added_secret = SecretKey::from_bytes(&[nonce.checked_add(0x20).unwrap(); 32]); + let added_controller = ControllerDescriptor::new( + SigningPublicKey::ed25519(*added_secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap(); + let body = EventBody::new( + typed_id::(nonce.checked_add(0x30).unwrap()), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id(nonce.checked_add(0x31).unwrap())), + AccountOperation::AddController(added_controller), + Timestamp::from_unix_millis(10), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(nonce.checked_add(0x32).unwrap()); + let admission = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + typed_id::(nonce.checked_add(0x33).unwrap()), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let approval = ControllerApprovalBody::event( + controller.id().unwrap(), + admission.event_id_for_body(&body).unwrap(), + admission.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + (body, admission, approval, controller) +} + +#[test] +fn credential_export_discloses_only_selected_claims_and_binds_exact_authority() { + let issuer_secret = SecretKey::from_bytes(&[0x41; 32]); + let issuer_key = SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(); + let subject_key = + SigningPublicKey::ed25519(*SecretKey::from_bytes(&[0x42; 32]).public().as_bytes()).unwrap(); + let account = typed_id::(0x43); + let checkpoint = typed_id::(0x44); + let selected = CredentialClaim::try_new("display-name", b"Ada".to_vec()).unwrap(); + let omitted_value = b"ada@example.invalid"; + let body = PortableCredentialBody::try_new( + account, + checkpoint, + Epoch::GENESIS, + vec![subject_key], + account, + issuer_key, + Timestamp::from_unix_millis(10), + Timestamp::from_unix_millis(20), + vec![selected], + Extensions::default(), + ) + .unwrap(); + let export_bytes = body.to_canonical_bytes().unwrap(); + assert!( + !export_bytes + .windows(omitted_value.len()) + .any(|window| window == omitted_value) + ); + + let signer = FakeOfflineSigner { + secret: issuer_secret, + observed: RefCell::new(Vec::new()), + }; + let request = CanonicalSigningRequest::for_portable_credential(&body).unwrap(); + assert_eq!(request.purpose(), SigningPurpose::PortableCredential); + assert_eq!(request.account_id(), account); + assert_eq!(request.signer_account_id(), account); + assert_eq!(request.account_epoch(), Epoch::GENESIS); + assert_eq!(request.operation_kind(), None); + assert_eq!(request.expected_signing_key(), issuer_key); + let signature = signer.sign_exact(&request).unwrap(); + assert_eq!( + signer.observed.borrow().as_slice(), + body.signing_bytes().unwrap() + ); + let credential = SignedPortableCredential::try_new(body, signature).unwrap(); + let encoded = credential.to_canonical_bytes().unwrap(); + assert_eq!( + SignedPortableCredential::from_canonical_bytes(&encoded).unwrap(), + credential + ); + assert_eq!( + blake3::hash(&encoded).as_bytes(), + &[ + 0x64, 0x6d, 0x54, 0xda, 0xec, 0x92, 0xd0, 0x84, 0x33, 0xff, 0xde, 0x0a, 0xc2, 0xa8, + 0x5b, 0xb3, 0xd3, 0xba, 0x6d, 0x55, 0xb1, 0xe8, 0xb0, 0x28, 0x37, 0x2b, 0x7a, 0xe1, + 0x4b, 0x98, 0x94, 0x65, + ] + ); + let context = CredentialVerificationContext::try_new( + account, + checkpoint, + Epoch::GENESIS, + account, + issuer_key, + Timestamp::from_unix_millis(19), + ) + .unwrap(); + let verified = verify_portable_credential(&credential, &context).unwrap(); + assert_eq!(verified.claims()[0].name(), "display-name"); + + let substituted = CredentialVerificationContext::try_new( + account, + typed_id::(0x45), + Epoch::GENESIS, + account, + issuer_key, + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert!(verify_portable_credential(&credential, &substituted).is_err()); + + for invalid in [ + CredentialVerificationContext::try_new( + typed_id::(0x46), + checkpoint, + Epoch::GENESIS, + account, + issuer_key, + Timestamp::from_unix_millis(19), + ) + .unwrap(), + CredentialVerificationContext::try_new( + account, + checkpoint, + Epoch::new(2), + account, + issuer_key, + Timestamp::from_unix_millis(19), + ) + .unwrap(), + CredentialVerificationContext::try_new( + account, + checkpoint, + Epoch::GENESIS, + typed_id::(0x47), + issuer_key, + Timestamp::from_unix_millis(19), + ) + .unwrap(), + CredentialVerificationContext::try_new( + account, + checkpoint, + Epoch::GENESIS, + account, + SigningPublicKey::ed25519(*SecretKey::from_bytes(&[0x48; 32]).public().as_bytes()) + .unwrap(), + Timestamp::from_unix_millis(19), + ) + .unwrap(), + ] { + assert!(verify_portable_credential(&credential, &invalid).is_err()); + } + let expired = CredentialVerificationContext::try_new( + account, + checkpoint, + Epoch::GENESIS, + account, + issuer_key, + Timestamp::from_unix_millis(20), + ) + .unwrap(); + assert_eq!( + verify_portable_credential(&credential, &expired), + Err(IdentityError::StaleEvidence) + ); + assert_eq!( + SignedPortableCredential::try_new( + credential.body().clone(), + AlgorithmSignature::new(1, vec![0x49; 64]).unwrap(), + ), + Err(IdentityError::InvalidSignature) + ); +} + +#[test] +fn hardware_boundary_receives_only_exact_typed_approval_bytes() { + let secret = SecretKey::from_bytes(&[0x51; 32]); + let signing_key = SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(); + let (body, admission, approval, controller) = account_approval_fixture(signing_key, 0x11); + let message = approval.to_canonical_bytes().unwrap(); + let fake = FakeOfflineSigner { + secret, + observed: RefCell::new(Vec::new()), + }; + let request = + HardwareApprovalRequest::for_account_approval(&body, &admission, &approval, &controller) + .unwrap(); + let signature = fake.approve_exact(&request).unwrap(); + assert_eq!(fake.observed.borrow().as_slice(), message); + request.verify_response(&signature).unwrap(); + assert_eq!(request.protocol_version(), ProtocolVersion::V1); + assert_eq!(request.account_id(), body.account_id()); + assert_eq!(request.resulting_epoch(), body.resulting_epoch()); + assert_eq!(request.operation_kind(), OperationKind::AddController); + assert_eq!(request.expected_signing_key(), signing_key); +} + +#[test] +fn offline_and_hardware_account_approval_boundaries_sign_only_the_exact_body() { + let secret = SecretKey::from_bytes(&[0x61; 32]); + let signing_key = SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(); + let (body, admission, approval, controller) = account_approval_fixture(signing_key, 0x12); + let approval_bytes = approval.to_canonical_bytes().unwrap(); + let fake = FakeOfflineSigner { + secret, + observed: RefCell::new(Vec::new()), + }; + + let offline_request = + CanonicalSigningRequest::for_account_approval(&body, &admission, &approval, &controller) + .unwrap(); + let offline_signature = fake.sign_exact(&offline_request).unwrap(); + assert_eq!(fake.observed.borrow().as_slice(), approval_bytes); + assert_eq!(offline_request.purpose(), SigningPurpose::AccountApproval); + assert_eq!(offline_request.account_id(), body.account_id()); + assert_eq!(offline_request.signer_account_id(), body.account_id()); + assert_eq!(offline_request.account_epoch(), body.resulting_epoch()); + assert_eq!( + offline_request.operation_kind(), + Some(OperationKind::AddController) + ); + offline_request.verify_response(&offline_signature).unwrap(); + + let hardware_request = + HardwareApprovalRequest::for_account_approval(&body, &admission, &approval, &controller) + .unwrap(); + let hardware_signature = fake.approve_exact(&hardware_request).unwrap(); + assert_eq!(fake.observed.borrow().as_slice(), approval_bytes); + assert_eq!( + hardware_request.operation_kind(), + OperationKind::AddController + ); + assert_eq!(hardware_request.expected_signing_key(), signing_key); + hardware_request + .verify_response(&hardware_signature) + .unwrap(); + + let (substituted_body, substituted_admission, substituted_approval, substituted_controller) = + account_approval_fixture(signing_key, 0x13); + let substituted = HardwareApprovalRequest::for_account_approval( + &substituted_body, + &substituted_admission, + &substituted_approval, + &substituted_controller, + ) + .unwrap(); + assert_eq!( + substituted.verify_response(&hardware_signature), + Err(IdentityError::InvalidSignature) + ); + + assert!( + CanonicalSigningRequest::for_account_approval( + &substituted_body, + &admission, + &approval, + &controller, + ) + .is_err(), + "a host cannot combine display context from one event with another event's approval" + ); + assert!( + HardwareApprovalRequest::for_account_approval( + &body, + &substituted_admission, + &approval, + &controller, + ) + .is_err(), + "a host cannot substitute admission evidence behind the signer's display" + ); + + let wrong_secret = SecretKey::from_bytes(&[0x62; 32]); + let wrong_controller = ControllerDescriptor::new( + SigningPublicKey::ed25519(*wrong_secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap(); + assert!( + CanonicalSigningRequest::for_account_approval( + &body, + &admission, + &approval, + &wrong_controller, + ) + .is_err(), + "a host cannot substitute the displayed key/controller" + ); + + let scoped_controller = ControllerDescriptor::new( + signing_key, + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::operations(vec![OperationKind::CancelRecovery]).unwrap(), + Extensions::default(), + ) + .unwrap(); + let scoped_approval = ControllerApprovalBody::event( + scoped_controller.id().unwrap(), + admission.event_id_for_body(&body).unwrap(), + admission.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + assert_eq!( + HardwareApprovalRequest::for_account_approval( + &body, + &admission, + &scoped_approval, + &scoped_controller, + ) + .err(), + Some(IdentityError::IneligibleController) + ); +} + +#[test] +fn privacy_sensitive_debug_surfaces_are_redacted() { + let blinding = BlindingSecret::try_new([0x71; 32]).unwrap(); + let label = PrivateLabel::try_new(b"private-family-label".to_vec()).unwrap(); + let lookup = LookupHandleSecret::try_new([0x72; 32]).unwrap(); + let pairwise = PairwiseMasterSecret::try_new([0x73; 32]).unwrap(); + assert_eq!(format!("{blinding:?}"), "BlindingSecret()"); + assert_eq!(format!("{label:?}"), "PrivateLabel()"); + assert_eq!(format!("{lookup:?}"), "LookupHandleSecret()"); + assert_eq!(format!("{pairwise:?}"), "PairwiseMasterSecret()"); + + let claim = CredentialClaim::try_new("email", b"private@example.invalid".to_vec()).unwrap(); + let debug = format!("{claim:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("private@example.invalid")); + + let signing_key = + SigningPublicKey::ed25519(*SecretKey::from_bytes(&[0x74; 32]).public().as_bytes()).unwrap(); + let (body, admission, approval, controller) = account_approval_fixture(signing_key, 0x14); + let exact_message = approval.to_canonical_bytes().unwrap(); + let request = + CanonicalSigningRequest::for_account_approval(&body, &admission, &approval, &controller) + .unwrap(); + let debug = format!("{request:?}"); + assert!(debug.contains("")); + assert!(!debug.contains(&hex::encode(exact_message))); +} diff --git a/protocols/krikos-identity/tests/private_artifacts.rs b/protocols/krikos-identity/tests/private_artifacts.rs new file mode 100644 index 00000000000..b3852b866ca --- /dev/null +++ b/protocols/krikos-identity/tests/private_artifacts.rs @@ -0,0 +1,170 @@ +use std::{convert::Infallible, fmt}; + +use krikos_identity::{ + AccountId, ApplicationId, CanonicalWire, CheckpointId, Digest, Epoch, Extensions, + HashAlgorithm, IdentityError, PrivateArtifactContext, PrivateMetadata, PrivateMetadataEnvelope, + PrivateMetadataKey, +}; +use rand_core::{TryCryptoRng, TryRng}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn context() -> PrivateArtifactContext { + PrivateArtifactContext::try_new( + typed_id::(1), + typed_id::(2), + Epoch::new(3), + Some(typed_id::(4)), + 5, + Extensions::default(), + ) + .unwrap() +} + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +#[derive(Debug, Clone, Copy)] +struct InjectedEntropyFailure; + +impl fmt::Display for InjectedEntropyFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("injected entropy failure") + } +} + +impl std::error::Error for InjectedEntropyFailure {} + +struct FailingRng; + +impl TryRng for FailingRng { + type Error = InjectedEntropyFailure; + + fn try_next_u32(&mut self) -> Result { + Err(InjectedEntropyFailure) + } + + fn try_next_u64(&mut self) -> Result { + Err(InjectedEntropyFailure) + } + + fn try_fill_bytes(&mut self, _destination: &mut [u8]) -> Result<(), Self::Error> { + Err(InjectedEntropyFailure) + } +} + +impl TryCryptoRng for FailingRng {} + +#[test] +fn private_metadata_is_deterministic_with_injected_entropy_and_round_trips() { + let key = PrivateMetadataKey::try_new([0x31; 32]).unwrap(); + let plaintext = PrivateMetadata::try_new(b"private profile: alpine orchid".to_vec()).unwrap(); + + let first = PrivateMetadataEnvelope::seal_with_rng( + context(), + &key, + &plaintext, + &mut RepeatingRng(0x41), + ) + .unwrap(); + let second = PrivateMetadataEnvelope::seal_with_rng( + context(), + &key, + &plaintext, + &mut RepeatingRng(0x41), + ) + .unwrap(); + assert_eq!( + first.to_canonical_bytes().unwrap(), + second.to_canonical_bytes().unwrap() + ); + assert_eq!( + PrivateMetadataEnvelope::from_canonical_bytes(&first.to_canonical_bytes().unwrap()) + .unwrap(), + first + ); + assert_eq!(first.open(&key).unwrap().as_bytes(), plaintext.as_bytes()); + + let encoded = first.to_canonical_bytes().unwrap(); + assert!( + !encoded + .windows(plaintext.as_bytes().len()) + .any(|window| window == plaintext.as_bytes()) + ); + assert_eq!(format!("{key:?}"), "PrivateMetadataKey()"); + assert_eq!(format!("{plaintext:?}"), "PrivateMetadata()"); +} + +#[test] +fn wrong_key_and_ciphertext_corruption_share_one_failure() { + let key = PrivateMetadataKey::try_new([0x32; 32]).unwrap(); + let wrong = PrivateMetadataKey::try_new([0x33; 32]).unwrap(); + let plaintext = PrivateMetadata::try_new(vec![0x55; 128]).unwrap(); + let envelope = PrivateMetadataEnvelope::seal_with_rng( + context(), + &key, + &plaintext, + &mut RepeatingRng(0x42), + ) + .unwrap(); + assert_eq!( + envelope.open(&wrong), + Err(IdentityError::PrivateArtifactAuthenticationFailed) + ); + + let mut corrupted = envelope.to_canonical_bytes().unwrap(); + let ciphertext_byte = corrupted + .len() + .checked_sub(2) + .and_then(|index| corrupted.get_mut(index)) + .unwrap(); + *ciphertext_byte ^= 1; + let corrupted = PrivateMetadataEnvelope::from_canonical_bytes(&corrupted).unwrap(); + assert_eq!( + corrupted.open(&key), + Err(IdentityError::PrivateArtifactAuthenticationFailed) + ); +} + +#[test] +fn metadata_bounds_and_entropy_failure_are_typed() { + assert!(matches!( + PrivateMetadata::try_new(Vec::new()), + Err(IdentityError::EmptyCollection { .. }) + )); + assert!(matches!( + PrivateMetadata::try_new(vec![ + 0; + krikos_identity::limits::MAX_PRIVATE_METADATA_BYTES + 1 + ]), + Err(IdentityError::LimitExceeded { .. }) + )); + + let key = PrivateMetadataKey::try_new([0x34; 32]).unwrap(); + let plaintext = PrivateMetadata::try_new(vec![0x56; 32]).unwrap(); + assert_eq!( + PrivateMetadataEnvelope::seal_with_rng(context(), &key, &plaintext, &mut FailingRng), + Err(IdentityError::EntropyUnavailable) + ); +} diff --git a/protocols/krikos-identity/tests/private_backup.rs b/protocols/krikos-identity/tests/private_backup.rs new file mode 100644 index 00000000000..aedc86d178a --- /dev/null +++ b/protocols/krikos-identity/tests/private_backup.rs @@ -0,0 +1,412 @@ +use std::{convert::Infallible, fmt}; + +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, AdmissionEvidence, AlgorithmSignature, + ApplicationBackupData, ApplicationDataRestoration, BackupAuthorityBundle, BackupEnvelope, + BackupPassphrase, CanonicalWire, CheckpointAuthorization, CheckpointBody, CheckpointId, + ControlPolicy, ControllerApprovalBody, ControllerApprovals, ControllerClass, + ControllerDescriptor, ControllerKeyId, ControllerScope, ControllerSelector, + ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, DelayEvidence, Digest, + DurationMillis, EventBody, EventPredecessors, Extensions, FreshnessEvidence, + FreshnessRequirement, HashAlgorithm, IdentityError, KeyedSignature, OperationKind, PolicyRule, + PrivateArtifactContext, ProviderPolicy, ProviderPolicyVersion, RecoveryAuthority, + RecoveryPolicy, RecoveryPolicyVersion, RequiredWeight, Sequence, SignedCheckpoint, + SignedControllerApproval, SigningPublicKey, Timestamp, build_checkpoint_body, +}; +use rand_core::{TryCryptoRng, TryRng}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn genesis(signer: &SecretKey) -> AccountGenesis { + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::ChangeProviderPolicy, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + AccountGenesis::new( + [0x12; 32], + Timestamp::from_unix_millis(1), + policy, + vec![controller(signer)], + recovery, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +fn authorized_event(state: &AccountState, signer: &SecretKey) -> krikos_identity::AuthorizedEvent { + let operation = + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x13; 32]))); + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.expected_epoch_for(&operation).unwrap(), + predecessors, + operation, + Timestamp::from_unix_millis(2), + [0x14; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x21); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let approval_body = ControllerApprovalBody::event( + state.active_controllers()[0].id(), + evidence.event_id_for_body(&body).unwrap(), + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn signed_checkpoint( + state: &AccountState, + signer: &SecretKey, + body: CheckpointBody, +) -> SignedCheckpoint { + let checkpoint_id = body.checkpoint_id().unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = + ControllerApprovalBody::checkpoint(controller_id, checkpoint_id, Extensions::default()) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + SignedCheckpoint::new( + body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap() +} + +fn authority_fixture() -> (BackupAuthorityBundle, AccountState) { + let signer = SecretKey::from_bytes(&[0x11; 32]); + let genesis = genesis(&signer); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let event = authorized_event(&state, &signer); + state.validate_and_apply(&event).unwrap(); + let checkpoint = signed_checkpoint( + &state, + &signer, + build_checkpoint_body(&state, Timestamp::from_unix_millis(99)).unwrap(), + ); + ( + BackupAuthorityBundle::try_new(genesis, vec![event], checkpoint).unwrap(), + state, + ) +} + +fn context(bundle: &BackupAuthorityBundle) -> PrivateArtifactContext { + PrivateArtifactContext::try_new( + bundle.account_id(), + bundle.checkpoint_id(), + bundle.account_epoch(), + None, + 1, + Extensions::default(), + ) + .unwrap() +} + +struct RepeatingRng(u8); + +impl TryRng for RepeatingRng { + type Error = Infallible; + + fn try_next_u32(&mut self) -> Result { + Ok(u32::from(self.0)) + } + + fn try_next_u64(&mut self) -> Result { + Ok(u64::from(self.0)) + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), Self::Error> { + destination.fill(self.0); + Ok(()) + } +} + +impl TryCryptoRng for RepeatingRng {} + +#[derive(Debug)] +struct RngFailure; + +impl fmt::Display for RngFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("injected entropy failure") + } +} + +impl std::error::Error for RngFailure {} + +struct FailedRng; + +impl TryRng for FailedRng { + type Error = RngFailure; + + fn try_next_u32(&mut self) -> Result { + Err(RngFailure) + } + + fn try_next_u64(&mut self) -> Result { + Err(RngFailure) + } + + fn try_fill_bytes(&mut self, _destination: &mut [u8]) -> Result<(), Self::Error> { + Err(RngFailure) + } +} + +impl TryCryptoRng for FailedRng {} + +#[test] +fn backup_round_trip_validates_authority_and_separates_application_data() { + let (bundle, expected_state) = authority_fixture(); + let passphrase = BackupPassphrase::try_new(b"correct horse battery staple".to_vec()).unwrap(); + let without_data = BackupEnvelope::seal_with_rng( + context(&bundle), + &passphrase, + &bundle, + None, + &mut RepeatingRng(0x61), + ) + .unwrap(); + let restored = without_data.restore(&passphrase).unwrap(); + assert_eq!(restored.account_authority().state(), &expected_state); + assert_eq!( + restored.account_authority().checkpoint_id(), + bundle.checkpoint_id() + ); + assert!(matches!( + restored.application_data(), + ApplicationDataRestoration::Unavailable + )); + + let app_data = ApplicationBackupData::try_new(b"wrapped app keys".to_vec()).unwrap(); + let with_data = BackupEnvelope::seal_with_rng( + context(&bundle), + &passphrase, + &bundle, + Some(&app_data), + &mut RepeatingRng(0x62), + ) + .unwrap(); + let restored = with_data.restore(&passphrase).unwrap(); + let ApplicationDataRestoration::Restored(restored_data) = restored.application_data() else { + panic!("authenticated application backup data must be reported as restored"); + }; + assert_eq!(restored_data.as_bytes(), app_data.as_bytes()); +} + +#[test] +fn backup_wrong_passphrase_and_corruption_are_uniform() { + let (bundle, _) = authority_fixture(); + let passphrase = BackupPassphrase::try_new(b"correct horse battery staple".to_vec()).unwrap(); + let wrong = BackupPassphrase::try_new(b"correct horse battery stapler".to_vec()).unwrap(); + let envelope = BackupEnvelope::seal_with_rng( + context(&bundle), + &passphrase, + &bundle, + None, + &mut RepeatingRng(0x63), + ) + .unwrap(); + assert!(matches!( + envelope.restore(&wrong), + Err(IdentityError::PrivateArtifactAuthenticationFailed) + )); + + let mut corrupted = envelope.to_canonical_bytes().unwrap(); + let ciphertext_byte = corrupted + .len() + .checked_sub(2) + .and_then(|index| corrupted.get_mut(index)) + .unwrap(); + *ciphertext_byte ^= 1; + let corrupted = BackupEnvelope::from_canonical_bytes(&corrupted).unwrap(); + assert!(matches!( + corrupted.restore(&passphrase), + Err(IdentityError::PrivateArtifactAuthenticationFailed) + )); +} + +#[test] +fn invalid_authority_and_passphrase_inputs_fail_before_restore() { + let signer = SecretKey::from_bytes(&[0x11; 32]); + let genesis = genesis(&signer); + let state = AccountState::from_genesis(&genesis).unwrap(); + assert!( + BackupAuthorityBundle::try_new( + genesis, + Vec::new(), + // This placeholder is intentionally unavailable because a genesis-only state cannot + // produce a valid checkpoint; use the valid fixture's checkpoint to prove mismatch. + authority_fixture().0.checkpoint().clone(), + ) + .is_err() + ); + assert_eq!(state.sequence(), Sequence::GENESIS); + + assert!(matches!( + BackupPassphrase::try_new(Vec::new()), + Err(IdentityError::EmptyCollection { .. }) + )); + assert!(matches!( + BackupPassphrase::try_new(vec![0; 1025]), + Err(IdentityError::LimitExceeded { .. }) + )); + assert_eq!( + format!( + "{:?}", + BackupPassphrase::try_new(b"secret".to_vec()).unwrap() + ), + "BackupPassphrase()" + ); + + assert!(matches!( + ApplicationBackupData::try_new(vec![ + 0; + krikos_identity::limits::MAX_APPLICATION_BACKUP_DATA_BYTES + + 1 + ]), + Err(IdentityError::LimitExceeded { .. }) + )); +} + +#[test] +fn backup_vector_rejects_version_and_kdf_parameter_substitution_before_restore() { + let (bundle, _) = authority_fixture(); + let passphrase = BackupPassphrase::try_new(b"correct horse battery staple".to_vec()).unwrap(); + let mut entropy = RepeatingRng(0x64); + let envelope = + BackupEnvelope::seal_with_rng(context(&bundle), &passphrase, &bundle, None, &mut entropy) + .unwrap(); + let encoded = envelope.to_canonical_bytes().unwrap(); + assert_eq!(&encoded[..4], &[1, 2, 1, 19]); + assert_eq!( + blake3::hash(&encoded).as_bytes(), + &[ + 0x9e, 0xeb, 0xd1, 0x8a, 0xd7, 0x20, 0xd4, 0x0e, 0x91, 0x99, 0xb9, 0x64, 0x6c, 0x63, + 0x04, 0xc6, 0x9c, 0x93, 0xb0, 0x10, 0xde, 0xbb, 0x7a, 0x5a, 0x5a, 0xbe, 0xfe, 0x45, + 0x18, 0xb1, 0x3d, 0x7e, + ] + ); + + let mut wrong_version = encoded.clone(); + wrong_version[0] = 2; + assert!(BackupEnvelope::from_canonical_bytes(&wrong_version).is_err()); + + let mut wrong_kdf = encoded; + wrong_kdf[2] = 2; + assert!(BackupEnvelope::from_canonical_bytes(&wrong_kdf).is_err()); +} + +#[test] +fn backup_injected_entropy_failure_is_retryable_and_emits_no_envelope() { + let (bundle, _) = authority_fixture(); + let passphrase = BackupPassphrase::try_new(b"correct horse battery staple".to_vec()).unwrap(); + assert!(matches!( + BackupEnvelope::seal_with_rng(context(&bundle), &passphrase, &bundle, None, &mut FailedRng,), + Err(IdentityError::EntropyUnavailable) + )); +} diff --git a/protocols/krikos-identity/tests/provider_fuzz_corpus.rs b/protocols/krikos-identity/tests/provider_fuzz_corpus.rs new file mode 100644 index 00000000000..21f467771ec --- /dev/null +++ b/protocols/krikos-identity/tests/provider_fuzz_corpus.rs @@ -0,0 +1,101 @@ +use krikos_identity::{ + CanonicalWire, OpaqueProviderAnchorCommitment, ProviderAuditExportChunk, + ProviderAuditExportManifest, ProviderCompactionManifest, ProviderExportComponent, + ProviderExportComponentDescriptor, ProviderGenerationExportChunk, + ProviderGenerationExportManifest, ProviderRecoveryExportManifest, +}; + +fn payload(seed: &[u8], selector: u8) -> &[u8] { + assert_eq!(seed.first(), Some(&selector)); + &seed[1..] +} + +fn assert_corpus_pair(accepted: &[u8], malformed: &[u8], selector: u8) { + assert!(T::from_canonical_bytes(payload(accepted, selector)).is_ok()); + assert!(T::from_canonical_bytes(payload(malformed, selector)).is_err()); +} + +#[test] +fn provider_interchange_corpus_keeps_append_only_ascii_selectors_and_fail_closed_seeds() { + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-07-provider-export-component-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-07-provider-export-component-malformed-truncated.bin" + ), + b'7', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-08-provider-export-component-descriptor-malformed-truncated.bin" + ), + b'8', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-09-provider-generation-export-chunk-malformed-truncated.bin" + ), + b'9', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0a-provider-audit-export-chunk-malformed-truncated.bin" + ), + b'a', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0b-provider-generation-export-manifest-malformed-truncated.bin" + ), + b'b', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0c-provider-audit-export-manifest-malformed-truncated.bin" + ), + b'c', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0d-provider-recovery-export-manifest-malformed-truncated.bin" + ), + b'd', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0e-provider-compaction-manifest-malformed-truncated.bin" + ), + b'e', + ); + assert_corpus_pair::( + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-accepted.bin" + ), + include_bytes!( + "../../../fuzz/corpus/identity_provider/selector-0f-opaque-provider-anchor-commitment-malformed-truncated.bin" + ), + b'f', + ); +} diff --git a/protocols/krikos-identity/tests/provider_persistence.rs b/protocols/krikos-identity/tests/provider_persistence.rs new file mode 100644 index 00000000000..1c72bc6ea10 --- /dev/null +++ b/protocols/krikos-identity/tests/provider_persistence.rs @@ -0,0 +1,2179 @@ +#[cfg(feature = "provider-store")] +use std::{ + collections::BTreeSet, + sync::{Arc, Barrier}, + thread, +}; + +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountId, AccountOperation, AccountState, AdmissionEvidence, + AlgorithmSignature, AuthorizedEvent, CanonicalWire, CheckpointAuthorization, CheckpointId, + ControlPolicy, ControllerApprovalBody, ControllerApprovals, ControllerClass, + ControllerDescriptor, ControllerKeyId, ControllerScope, ControllerSelector, + ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, DelayEvidence, Digest, + DurableProviderAuditor, DurationMillis, Epoch, EventBody, EventId, EventPredecessors, + Extensions, FreshnessEvidence, FreshnessRequirement, HashAlgorithm, IdentityError, + InclusionReceipt, KeyedSignature, MemoryProviderAuditStore, MemoryProviderStore, + OpaqueProviderAnchorCommitment, OperationKind, PolicyRule, ProtocolSignature, + ProviderAdmissionControl, ProviderAdmissionRequest, ProviderAuditStatus, + ProviderCheckpointBundle, ProviderCompactionManifest, ProviderDescriptor, + ProviderGenerationExport, ProviderGenerationExportAssembler, ProviderHeadAuditDisposition, + ProviderHeadBody, ProviderHeadSigner, ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, + ProviderPolicy, ProviderPolicyVersion, ProviderRecoveryExport, ProviderRetentionClass, + ProviderRetentionInventory, ProviderRetentionItem, RecoveryAuthority, RecoveryPolicy, + RecoveryPolicyVersion, RequiredWeight, Sequence, SignedCheckpoint, SignedControllerApproval, + SignedProviderHead, SigningPublicKey, Timestamp, authorize_provider_append, + bootstrap_checkpoint_from_genesis, bootstrap_checkpoint_from_prior, build_checkpoint_body, + build_provider_checkpoint_bundle_from_genesis, build_provider_checkpoint_bundle_from_prior, + derive_provider_retention_inventory, verify_checkpoint, verify_provider_compaction, +}; +#[cfg(feature = "provider-store")] +use krikos_identity::{ + ProviderGenerationRegistry, ProviderGenerationRoute, ProviderQuorum, RedbProviderAuditStore, + RedbProviderStore, +}; +#[cfg(feature = "provider-store")] +use redb::{Database, ReadableTable, TableDefinition}; + +#[cfg(feature = "provider-store")] +const TEST_PROVIDER_COMMITTED_TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-provider-generation-v1"); + +#[derive(serde::Serialize)] +struct ProviderAnchorCommitmentMirror<'a> { + format_version: u16, + manifest: &'a ProviderCompactionManifest, +} + +#[derive(serde::Serialize)] +struct ProviderCheckpointBundleCommitmentMirror<'a> { + genesis: Option<&'a AccountGenesis>, + prior_checkpoint_id: Option, + events: &'a [krikos_identity::AuthorizedEvent], + checkpoint: &'a SignedCheckpoint, + transition_event: Option<&'a krikos_identity::AuthorizedEvent>, +} + +#[derive(serde::Serialize)] +struct ProviderGenerationExportCommitmentMirror<'a> { + format_version: u16, + provider: &'a ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + entries: &'a [ProviderLogEntryBody], + leaf_hashes: &'a [Digest], + latest_head: Option<&'a SignedProviderHead>, + receipts: &'a [InclusionReceipt], + checkpoint_bundles: Vec>, + compaction_manifests: &'a [ProviderCompactionManifest], +} + +#[derive(serde::Serialize)] +struct ProviderAuditArtifactSetCommitmentMirror<'a> { + format_version: u16, + artifact_commitments: &'a [Digest], +} + +#[derive(serde::Serialize)] +struct RetainedProviderRecordCommitmentMirror<'a> { + leaf_index: u64, + entry: &'a ProviderLogEntryBody, + receipt: &'a InclusionReceipt, +} + +#[derive(serde::Serialize)] +struct RetainedCheckpointMaterialCommitmentMirror<'a> { + genesis: Option<&'a AccountGenesis>, + prior_checkpoint_id: Option, + events: &'a [AuthorizedEvent], + checkpoint: &'a SignedCheckpoint, + transition_event: Option<&'a AuthorizedEvent>, +} + +#[derive(serde::Serialize)] +struct ProviderCheckpointIndexCommitmentMirror<'a> { + account_id: AccountId, + greatest_sequence: Sequence, + greatest_epoch: Epoch, + current_checkpoint_id: Option, + projection_heads: &'a [EventId], + forked: bool, +} + +#[derive(serde::Serialize)] +struct RetainedProviderEvidenceCommitmentMirror<'a> { + format_version: u16, + records: Vec>, + checkpoint_evidence: Vec>, + checkpoint_index: Vec>, + audit_artifact_commitment: Digest, +} + +fn raw_provider_commitment(domain: &[u8], value: &T) -> Digest { + let bytes = postcard::to_stdvec(value).unwrap(); + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(&bytes); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) +} + +fn raw_provider_generation_commitment(export: &ProviderGenerationExport) -> Digest { + raw_provider_commitment( + b"KRIKOS-ID/provider-generation-export/v1", + &ProviderGenerationExportCommitmentMirror { + format_version: 1, + provider: export.provider(), + log_id: export.log_id(), + key_version: export.key_version(), + entries: export.entries(), + leaf_hashes: export.leaf_hashes(), + latest_head: export.latest_head(), + receipts: export.receipts(), + checkpoint_bundles: export + .checkpoint_bundles() + .iter() + .map(|bundle| { + let verified = bundle.verified_checkpoint(); + ProviderCheckpointBundleCommitmentMirror { + genesis: bundle.genesis(), + prior_checkpoint_id: bundle.prior_checkpoint_id(), + events: bundle.events(), + checkpoint: verified.checkpoint(), + transition_event: verified.transition_event(), + } + }) + .collect(), + compaction_manifests: export.compaction_manifests(), + }, + ) +} + +fn raw_single_checkpoint_retained_commitment(export: &ProviderGenerationExport) -> Digest { + assert_eq!(export.entries().len(), 1); + assert_eq!(export.receipts().len(), 1); + assert_eq!(export.checkpoint_bundles().len(), 1); + let bundle = &export.checkpoint_bundles()[0]; + let genesis = bundle.genesis().unwrap(); + let mut state = AccountState::from_genesis(genesis).unwrap(); + for event in bundle.events() { + state.validate_and_apply(event).unwrap(); + } + let verified = bundle.verified_checkpoint(); + let artifact_commitment = raw_provider_commitment( + b"KRIKOS-ID/provider-audit-artifacts/v1", + &ProviderAuditArtifactSetCommitmentMirror { + format_version: 1, + artifact_commitments: &[], + }, + ); + raw_provider_commitment( + b"KRIKOS-ID/provider-retained-evidence/v1", + &RetainedProviderEvidenceCommitmentMirror { + format_version: 1, + records: vec![RetainedProviderRecordCommitmentMirror { + leaf_index: 0, + entry: &export.entries()[0], + receipt: &export.receipts()[0], + }], + checkpoint_evidence: vec![RetainedCheckpointMaterialCommitmentMirror { + genesis: Some(genesis), + prior_checkpoint_id: bundle.prior_checkpoint_id(), + events: bundle.events(), + checkpoint: verified.checkpoint(), + transition_event: verified.transition_event(), + }], + checkpoint_index: vec![ProviderCheckpointIndexCommitmentMirror { + account_id: state.account_id(), + greatest_sequence: state.sequence(), + greatest_epoch: state.epoch(), + current_checkpoint_id: Some(verified.checkpoint_id()), + projection_heads: state.heads(), + forked: false, + }], + audit_artifact_commitment: artifact_commitment, + }, + ) +} + +#[cfg(feature = "provider-store")] +fn corrupt_unique_committed_subsequence(path: &std::path::Path, needle: &[u8]) { + let database = Database::create(path).unwrap(); + let write = database.begin_write().unwrap(); + { + let mut table = write.open_table(TEST_PROVIDER_COMMITTED_TABLE).unwrap(); + let value = table.get(b"active".as_slice()).unwrap().unwrap(); + let mut bytes = value.value().to_vec(); + drop(value); + let offsets = bytes + .windows(needle.len()) + .enumerate() + .filter_map(|(offset, candidate)| (candidate == needle).then_some(offset)) + .collect::>(); + assert_eq!( + offsets.len(), + 1, + "corruption target must be exact and unique" + ); + let target = offsets[0] + needle.len() / 2; + bytes[target] ^= 0x01; + table + .insert(b"active".as_slice(), bytes.as_slice()) + .unwrap(); + } + write.commit().unwrap(); +} + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn authorized_add_controller( + state: &AccountState, + signer: &SecretKey, + added: &SecretKey, + nonce: u64, +) -> krikos_identity::AuthorizedEvent { + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let nonce_bytes: [u8; 16] = nonce.to_le_bytes().repeat(2).try_into().unwrap(); + let event_body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state + .expected_epoch_for(&AccountOperation::AddController(controller(added))) + .unwrap(), + predecessors, + AccountOperation::AddController(controller(added)), + Timestamp::from_unix_millis(nonce), + nonce_bytes, + Extensions::default(), + ) + .unwrap(); + let admission_checkpoint = typed_id::(0x42); + let evidence = AdmissionEvidence::new( + event_body.proposal_id().unwrap(), + admission_checkpoint, + state.provider_policy_id(), + FreshnessEvidence::local_known(admission_checkpoint), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|candidate| candidate.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + evidence.event_id_for_body(&event_body).unwrap(), + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let approval_signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, approval_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + krikos_identity::AuthorizedEvent::new( + event_body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn checkpoint_bundle(nonce: u8) -> ProviderCheckpointBundle { + checkpoint_bundle_at(nonce, 1, nonce.wrapping_add(10), 10_000) +} + +fn alternate_checkpoint_approval_bundle( + retained: &ProviderCheckpointBundle, + alternate_secret: &SecretKey, +) -> ProviderCheckpointBundle { + let genesis = retained.genesis().unwrap(); + let mut state = AccountState::from_genesis(genesis).unwrap(); + for event in retained.events() { + state.validate_and_apply(event).unwrap(); + } + let alternate_key = SigningPublicKey::ed25519(*alternate_secret.public().as_bytes()).unwrap(); + let alternate_controller = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == alternate_key) + .unwrap() + .id(); + let retained_checkpoint = retained.verified_checkpoint().checkpoint(); + let checkpoint_id = retained_checkpoint.checkpoint_id().unwrap(); + let approval_body = ControllerApprovalBody::checkpoint( + alternate_controller, + checkpoint_id, + Extensions::default(), + ) + .unwrap(); + let approval = SignedControllerApproval::new( + approval_body.clone(), + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&alternate_key).unwrap(), + AlgorithmSignature::new( + 1, + alternate_secret + .sign(&approval_body.to_canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(), + )], + ) + .unwrap(); + let checkpoint = SignedCheckpoint::new( + retained_checkpoint.body().clone(), + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + build_provider_checkpoint_bundle_from_genesis(genesis, retained.events(), &checkpoint, None) + .unwrap() +} + +fn checkpoint_bundle_at( + nonce: u8, + event_count: u8, + branch_seed: u8, + checkpoint_issued_at: u64, +) -> ProviderCheckpointBundle { + let signer = SecretKey::from_bytes(&[0x41; 32]); + let control_policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::ChangeProviderPolicy, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [nonce; 32], + Timestamp::from_unix_millis(1), + control_policy, + vec![controller(&signer)], + recovery_policy, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let mut events = Vec::new(); + for event_index in 0..event_count { + let event_nonce = u64::from(branch_seed) + .saturating_mul(10) + .saturating_add(u64::from(event_index)) + .saturating_add(2); + let added = SecretKey::from_bytes(&[branch_seed.wrapping_add(event_index); 32]); + let event = authorized_add_controller(&state, &signer, &added, event_nonce); + state.validate_and_apply(&event).unwrap(); + events.push(event); + } + + let body = + build_checkpoint_body(&state, Timestamp::from_unix_millis(checkpoint_issued_at)).unwrap(); + let checkpoint_id = body.checkpoint_id().unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let checkpoint_controller_id = state + .active_controllers() + .iter() + .find(|candidate| candidate.signing_key() == signing_key) + .unwrap() + .id(); + let checkpoint_approval_body = ControllerApprovalBody::checkpoint( + checkpoint_controller_id, + checkpoint_id, + Extensions::default(), + ) + .unwrap(); + let checkpoint_signature = signer.sign(&checkpoint_approval_body.to_canonical_bytes().unwrap()); + let checkpoint_approval = SignedControllerApproval::new( + checkpoint_approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, checkpoint_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + let checkpoint = SignedCheckpoint::new( + body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![checkpoint_approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + verify_checkpoint(&state, &checkpoint, None).unwrap(); + build_provider_checkpoint_bundle_from_genesis(&genesis, &events, &checkpoint, None).unwrap() +} + +fn continuation_bundle(prior: &ProviderCheckpointBundle) -> ProviderCheckpointBundle { + let signer = SecretKey::from_bytes(&[0x41; 32]); + let mut prior_state = AccountState::from_genesis(prior.genesis().unwrap()).unwrap(); + for event in prior.events() { + prior_state.validate_and_apply(event).unwrap(); + } + let mut next_state = prior_state.clone(); + let event = authorized_add_controller( + &next_state, + &signer, + &SecretKey::from_bytes(&[0x76; 32]), + 76, + ); + next_state.validate_and_apply(&event).unwrap(); + let body = build_checkpoint_body(&next_state, Timestamp::from_unix_millis(20_000)).unwrap(); + let checkpoint_id = body.checkpoint_id().unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = next_state + .active_controllers() + .iter() + .find(|candidate| candidate.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = + ControllerApprovalBody::checkpoint(controller_id, checkpoint_id, Extensions::default()) + .unwrap(); + let approval = SignedControllerApproval::new( + approval_body.clone(), + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new( + 1, + signer + .sign(&approval_body.to_canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(), + )], + ) + .unwrap(); + let checkpoint = SignedCheckpoint::new( + body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + build_provider_checkpoint_bundle_from_prior( + &prior_state, + prior.verified_checkpoint(), + &[event], + &checkpoint, + None, + ) + .unwrap() +} + +struct Allow; + +impl ProviderAdmissionControl for Allow { + fn check( + &self, + _admission: krikos_identity::ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +struct Deny; + +impl ProviderAdmissionControl for Deny { + fn check( + &self, + _admission: krikos_identity::ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Err(IdentityError::ProviderRateLimited) + } +} + +struct Signer(SecretKey); + +impl ProviderHeadSigner for Signer { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +fn signed_head( + provider: &ProviderDescriptor, + log_id: ProviderLogId, + tree_size: u64, + root_fill: u8, + observed_at: u64, + signer: &Signer, +) -> SignedProviderHead { + let body = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + tree_size, + Digest::new(HashAlgorithm::Blake3_256, [root_fill; 32]), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + let signature = signer + .sign_provider_head(&body.signing_bytes().unwrap()) + .unwrap(); + SignedProviderHead::new(body, signature) +} + +fn recovery_export(generation: ProviderGenerationExport) -> ProviderRecoveryExport { + let audit_store = + MemoryProviderAuditStore::new(generation.provider().clone(), generation.log_id()); + let auditor = DurableProviderAuditor::new(audit_store.clone()); + if let Some(head) = generation.latest_head() { + auditor.observe(head.clone(), None).unwrap(); + } + ProviderRecoveryExport::new(generation, audit_store.snapshot().unwrap()).unwrap() +} + +#[cfg(feature = "provider-store")] +fn recovery_export_with_attacks( + generation: ProviderGenerationExport, + signer: &Signer, +) -> ProviderRecoveryExport { + let provider = generation.provider().clone(); + let log_id = generation.log_id(); + let accepted = generation.latest_head().unwrap().clone(); + assert!(accepted.body().tree_size() >= 2); + let accepted_at = accepted.body().observed_at().as_unix_millis(); + let audit_store = MemoryProviderAuditStore::new(provider.clone(), log_id); + let auditor = DurableProviderAuditor::new(audit_store.clone()); + auditor.observe(accepted.clone(), None).unwrap(); + let rollback = signed_head( + &provider, + log_id, + accepted.body().tree_size() - 1, + 0xf1, + accepted_at.saturating_add(1), + signer, + ); + assert_eq!( + auditor.observe(rollback, None), + Err(IdentityError::ProviderRollback) + ); + let conflict = signed_head( + &provider, + log_id, + accepted.body().tree_size(), + 0xf2, + accepted_at.saturating_add(2), + signer, + ); + assert_eq!( + auditor.observe(conflict, None), + Err(IdentityError::ProviderEquivocation) + ); + ProviderRecoveryExport::new(generation, audit_store.snapshot().unwrap()).unwrap() +} + +#[cfg(feature = "provider-store")] +struct UnavailableSigner; + +#[cfg(feature = "provider-store")] +impl ProviderHeadSigner for UnavailableSigner { + fn sign_provider_head(&self, _message: &[u8]) -> Result { + Err(IdentityError::ProviderUnavailable) + } +} + +#[test] +fn verified_admission_is_atomic_idempotent_and_availability_controls_are_non_authoritative() { + let signer = Signer(SecretKey::from_bytes(&[0x51; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x52); + let store = + MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS).unwrap(); + let checkpoint = checkpoint_bundle(0x53); + let admission = checkpoint.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + let undercharged = ProviderAdmissionRequest::new(request.encoded_bytes() - 1).unwrap(); + + assert_eq!( + authorize_provider_append(admission.clone(), undercharged, &Allow), + Err(IdentityError::InvalidRelationship { + resource: "provider append request byte undercharge", + }) + ); + assert_eq!(store.snapshot().unwrap().tree_size(), 0); + + assert_eq!( + authorize_provider_append(admission.clone(), request, &Deny), + Err(IdentityError::ProviderRateLimited) + ); + assert_eq!(store.snapshot().unwrap().tree_size(), 0); + + let permit = authorize_provider_append(admission.clone(), request, &Allow).unwrap(); + let first = store + .append(permit, Timestamp::from_unix_millis(10), &signer) + .unwrap(); + first.verify(&provider).unwrap(); + let replay_permit = authorize_provider_append(admission.clone(), request, &Allow).unwrap(); + let replay = store + .append(replay_permit, Timestamp::from_unix_millis(11), &signer) + .unwrap(); + assert_eq!(first.leaf_index(), replay.leaf_index()); + assert_eq!(store.snapshot().unwrap().tree_size(), 1); + assert_eq!( + store.checkpoint_bundles().unwrap(), + vec![checkpoint.clone()] + ); + assert_eq!( + store + .latest_checkpoint_bundle(admission.account_id()) + .unwrap() + .unwrap(), + checkpoint + ); + + let page = store + .account_history(admission.account_id(), None, 1, 4 * 1024 * 1024) + .unwrap(); + assert_eq!(page.records().len(), 1); + assert_eq!(page.records()[0].entry(), first.entry()); + + let export = store.export_generation().unwrap(); + let mirror = MemoryProviderStore::restore_generation(export.clone()).unwrap(); + assert_eq!(mirror.snapshot().unwrap(), store.snapshot().unwrap()); + mirror + .consistency_proof(0, mirror.snapshot().unwrap().tree_size()) + .unwrap(); + + let mirror_export = mirror.export_generation().unwrap(); + let source_recovery = recovery_export(store.export_generation().unwrap()); + let mirror_recovery = recovery_export(mirror_export.clone()); + let mandatory = derive_provider_retention_inventory(&source_recovery).unwrap(); + assert_eq!(mandatory.tree_size(), 1); + assert_eq!(mandatory.items().len(), 1); + assert_eq!( + mandatory.items()[0].class(), + ProviderRetentionClass::CheckpointLineage + ); + let omitted = ProviderRetentionInventory::new(1, Vec::new()).unwrap(); + assert!(matches!( + verify_provider_compaction(&source_recovery, &mirror_recovery, &omitted,), + Err(IdentityError::InvalidRelationship { + resource: "provider compaction mandatory retention inventory", + }) + )); + let misclassified = ProviderRetentionInventory::new( + 1, + vec![ProviderRetentionItem::new(0, ProviderRetentionClass::Recovery).unwrap()], + ) + .unwrap(); + assert!(matches!( + verify_provider_compaction(&source_recovery, &mirror_recovery, &misclassified,), + Err(IdentityError::InvalidRelationship { + resource: "provider compaction mandatory retention inventory", + }) + )); + let inventory = ProviderRetentionInventory::new( + 1, + vec![ProviderRetentionItem::new(0, ProviderRetentionClass::CheckpointLineage).unwrap()], + ) + .unwrap(); + let authorization = + verify_provider_compaction(&source_recovery, &mirror_recovery, &inventory).unwrap(); + assert_eq!(source_recovery.artifacts(), &[]); + assert_eq!( + authorization.manifest().retained_evidence_commitment(), + raw_single_checkpoint_retained_commitment(source_recovery.generation()), + "retained evidence must bind nonempty checkpoint material and projection index" + ); + authorization + .manifest() + .verify(&source_recovery, &mirror_recovery, &inventory) + .unwrap(); + assert!(store.compaction_manifests().unwrap().is_empty()); + let recorded = store + .record_compaction_manifest(&authorization, &mirror_recovery, &inventory) + .unwrap(); + assert_eq!(&recorded, authorization.manifest()); + store + .record_compaction_manifest(&authorization, &mirror_recovery, &inventory) + .unwrap(); + assert_eq!(store.compaction_manifests().unwrap(), vec![recorded]); + let post_manifest_recovery = recovery_export(store.export_generation().unwrap()); + assert_eq!( + post_manifest_recovery.generation_commitment(), + raw_provider_generation_commitment(post_manifest_recovery.generation()), + "the complete nonempty generation and its recorded manifest must use the v1 preimage" + ); + assert_ne!( + post_manifest_recovery.generation_commitment(), + source_recovery.generation_commitment(), + "later generation commitments must include already-durable manifests" + ); + let next_inventory = derive_provider_retention_inventory(&post_manifest_recovery).unwrap(); + let next_authorization = verify_provider_compaction( + &post_manifest_recovery, + &post_manifest_recovery, + &next_inventory, + ) + .unwrap(); + assert_eq!( + next_authorization.manifest().generation_commitment(), + post_manifest_recovery.generation_commitment() + ); + assert_ne!( + next_authorization.manifest().archive_commitment(), + authorization.manifest().archive_commitment() + ); + let opaque = + OpaqueProviderAnchorCommitment::from_compaction_manifest(authorization.manifest()).unwrap(); + assert_ne!(opaque.as_bytes(), &[0; 32]); + assert_eq!( + opaque.digest(), + raw_provider_commitment( + b"KRIKOS-ID/provider-anchor-commitment/v1", + &ProviderAnchorCommitmentMirror { + format_version: 1, + manifest: authorization.manifest(), + }, + ) + ); + + let wrong_inventory = ProviderRetentionInventory::new(2, Vec::new()).unwrap(); + assert_eq!( + verify_provider_compaction(&recovery_export(export), &mirror_recovery, &wrong_inventory,), + Err(IdentityError::InvalidRelationship { + resource: "provider compaction inventory tree size", + }) + ); +} + +#[test] +fn duplicate_checkpoint_approvals_merge_without_adding_a_provider_leaf() { + let nonce = 0xf0; + let first = checkpoint_bundle(nonce); + let alternate = alternate_checkpoint_approval_bundle( + &first, + &SecretKey::from_bytes(&[nonce.wrapping_add(10); 32]), + ); + assert_ne!(first, alternate); + assert_eq!( + first.verified_checkpoint().checkpoint_id(), + alternate.verified_checkpoint().checkpoint_id() + ); + + let signer = Signer(SecretKey::from_bytes(&[0xf1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let mut merged_results = Vec::new(); + for (log_seed, ordered) in [(0xf2, [&first, &alternate]), (0xf3, [&alternate, &first])] { + let store = MemoryProviderStore::new( + provider.clone(), + typed_id::(log_seed), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let mut receipts = Vec::new(); + for bundle in ordered { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + receipts.push( + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(600), + &signer, + ) + .unwrap(), + ); + } + assert_eq!(receipts[0], receipts[1]); + assert_eq!(store.snapshot().unwrap().tree_size(), 1); + let merged = store + .latest_checkpoint_bundle(first.verified_checkpoint().checkpoint().body().account_id()) + .unwrap() + .unwrap(); + assert_eq!( + merged + .verified_checkpoint() + .checkpoint() + .authorization() + .controller_approvals() + .unwrap() + .as_slice() + .len(), + 2 + ); + merged_results.push(merged); + } + assert_eq!(merged_results[0], merged_results[1]); +} + +#[test] +fn immutable_memory_recovery_archive_rejects_new_compaction_manifests() { + let signer = Signer(SecretKey::from_bytes(&[0xf4; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let active = MemoryProviderStore::new( + provider, + typed_id::(0xf5), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let bundle = checkpoint_bundle(0xf6); + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + active + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(700), + &signer, + ) + .unwrap(); + let recovery = recovery_export(active.export_generation().unwrap()); + let inventory = derive_provider_retention_inventory(&recovery).unwrap(); + let authorization = verify_provider_compaction(&recovery, &recovery, &inventory).unwrap(); + let archive = MemoryProviderStore::restore_recovery(recovery.clone()).unwrap(); + + assert_eq!( + archive.record_compaction_manifest(&authorization, &recovery, &inventory), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!(archive.archived_recovery_export().unwrap(), recovery); +} + +#[cfg(feature = "provider-store")] +#[test] +fn redb_duplicate_approval_merge_and_archive_immutability_survive_reopen() { + let directory = tempfile::tempdir().unwrap(); + let signer = Signer(SecretKey::from_bytes(&[0xf7; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let first = checkpoint_bundle(0xf8); + let alternate = alternate_checkpoint_approval_bundle( + &first, + &SecretKey::from_bytes(&[0xf8_u8.wrapping_add(10); 32]), + ); + let account_id = first.verified_checkpoint().checkpoint().body().account_id(); + let mut merged_results = Vec::new(); + for (index, ordered) in [[&first, &alternate], [&alternate, &first]] + .into_iter() + .enumerate() + { + let path = directory.path().join(format!("duplicate-{index}.redb")); + let log_id = typed_id::(0xf9_u8.wrapping_add(u8::try_from(index).unwrap())); + { + let store = RedbProviderStore::open( + &path, + provider.clone(), + log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let mut receipts = Vec::new(); + for bundle in ordered { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + receipts.push( + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(800), + &signer, + ) + .unwrap(), + ); + } + assert_eq!(receipts[0], receipts[1]); + assert_eq!(store.snapshot().unwrap().tree_size(), 1); + } + let reopened = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + let merged = reopened + .latest_checkpoint_bundle(account_id) + .unwrap() + .unwrap(); + assert_eq!( + merged + .verified_checkpoint() + .checkpoint() + .authorization() + .controller_approvals() + .unwrap() + .as_slice() + .len(), + 2 + ); + merged_results.push(merged); + } + assert_eq!(merged_results[0], merged_results[1]); + + let active = MemoryProviderStore::new( + provider.clone(), + typed_id::(0xfc), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let admission = first.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + active + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(801), + &signer, + ) + .unwrap(); + let recovery = recovery_export(active.export_generation().unwrap()); + let inventory = derive_provider_retention_inventory(&recovery).unwrap(); + let authorization = verify_provider_compaction(&recovery, &recovery, &inventory).unwrap(); + let archive_path = directory.path().join("immutable-archive.redb"); + { + let archive = RedbProviderStore::restore_recovery(&archive_path, recovery.clone()).unwrap(); + assert_eq!( + archive.record_compaction_manifest(&authorization, &recovery, &inventory), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!(archive.archived_recovery_export().unwrap(), recovery); + } + let reopened = RedbProviderStore::open( + &archive_path, + provider, + typed_id::(0xfc), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + assert_eq!(reopened.archived_recovery_export().unwrap(), recovery); + drop(reopened); + RedbProviderStore::restore_recovery(&archive_path, recovery).unwrap(); +} + +#[test] +fn checkpoint_lineage_pages_stitch_and_bootstrap_an_explicit_retained_branch() { + let signer = Signer(SecretKey::from_bytes(&[0x81; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x82); + let store = + MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS).unwrap(); + let first = checkpoint_bundle(0x83); + let second = continuation_bundle(&first); + for (bundle, observed_at) in [(&first, 30_u64), (&second, 31_u64)] { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(observed_at), + &signer, + ) + .unwrap(); + } + let export = store.export_generation().unwrap(); + let (manifest, chunks) = export.interchange_parts().unwrap(); + let mut assembler = ProviderGenerationExportAssembler::new(manifest).unwrap(); + for chunk in chunks.into_iter().rev() { + assembler.insert(chunk).unwrap(); + } + assert_eq!(assembler.finish().unwrap(), export); + let account_id = first.verified_checkpoint().checkpoint().body().account_id(); + let second_id = second.verified_checkpoint().checkpoint_id(); + let first_id = first.verified_checkpoint().checkpoint_id(); + let first_page = store + .checkpoint_lineage_page(account_id, second_id, 1, 4 * 1024 * 1024) + .unwrap() + .unwrap(); + assert_eq!(first_page.checkpoints().len(), 1); + assert_eq!(first_page.next_prior_checkpoint_id(), Some(first_id)); + let second_page = store + .checkpoint_lineage_page( + account_id, + first_page.next_prior_checkpoint_id().unwrap(), + 1, + 4 * 1024 * 1024, + ) + .unwrap() + .unwrap(); + assert_eq!(second_page.next_prior_checkpoint_id(), None); + let mut stitched = first_page.checkpoints().to_vec(); + stitched.extend_from_slice(second_page.checkpoints()); + stitched.reverse(); + assert_eq!(stitched.len(), 2); + for checkpoint in &stitched { + checkpoint.receipt().verify(&provider).unwrap(); + } + let first_bootstrap = bootstrap_checkpoint_from_genesis( + stitched[0].bundle().genesis().unwrap(), + stitched[0].bundle().events(), + stitched[0].bundle().verified_checkpoint().checkpoint(), + stitched[0] + .bundle() + .verified_checkpoint() + .transition_event(), + &FreshnessEvidence::local_known(first_id), + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(100), + &[], + ) + .unwrap(); + let final_bootstrap = bootstrap_checkpoint_from_prior( + first_bootstrap.state(), + first_bootstrap.checkpoint(), + stitched[1].bundle().events(), + stitched[1].bundle().verified_checkpoint().checkpoint(), + stitched[1] + .bundle() + .verified_checkpoint() + .transition_event(), + &FreshnessEvidence::local_known(second_id), + FreshnessRequirement::latest_known(), + Timestamp::from_unix_millis(100), + &[], + ) + .unwrap(); + assert_eq!(final_bootstrap.checkpoint().checkpoint_id(), second_id); + assert!( + store + .checkpoint_lineage_page(account_id, typed_id::(0xfe), 1, 1024) + .unwrap() + .is_none() + ); +} + +#[test] +fn sealed_generation_releases_benign_history_but_keeps_current_checkpoint_proofs() { + let signer = Signer(SecretKey::from_bytes(&[0x84; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x85); + let store = + MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS).unwrap(); + let first = checkpoint_bundle(0x86); + let second = continuation_bundle(&first); + for (bundle, observed_at) in [(&first, 40_u64), (&second, 41_u64)] { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(observed_at), + &signer, + ) + .unwrap(); + } + let recovery = recovery_export(store.export_generation().unwrap()); + let inventory = derive_provider_retention_inventory(&recovery).unwrap(); + let authorization = verify_provider_compaction(&recovery, &recovery, &inventory).unwrap(); + assert_eq!( + store + .seal_after_verified_mirror(&authorization, &recovery, &inventory) + .unwrap(), + 1 + ); + assert_eq!( + store + .seal_after_verified_mirror(&authorization, &recovery, &inventory) + .unwrap(), + 1, + "an exact seal replay must return the original release result" + ); + let account_id = second + .verified_checkpoint() + .checkpoint() + .body() + .account_id(); + let second_id = second.verified_checkpoint().checkpoint_id(); + assert_eq!( + store.latest_checkpoint_bundle(account_id), + Err(IdentityError::ProviderArchiveRequired) + ); + let retained = store + .latest_retained_checkpoint_evidence(account_id) + .unwrap() + .unwrap(); + assert_eq!(retained.checkpoint().checkpoint_id().unwrap(), second_id); + assert_eq!( + retained.prior_checkpoint_id(), + Some(first.verified_checkpoint().checkpoint_id()) + ); + assert_eq!(retained.receipt().leaf_index(), 1); + assert_eq!( + store.checkpoint_bundle(account_id, second_id), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + store + .retained_checkpoint_evidence(account_id, first.verified_checkpoint().checkpoint_id(),), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + store.account_history(account_id, None, 1, 4 * 1024 * 1024), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + store.export_generation(), + Err(IdentityError::ProviderArchiveRequired) + ); + let admission = first.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + assert_eq!( + store.append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(42), + &signer, + ), + Err(IdentityError::ProviderArchiveRequired) + ); +} + +#[cfg(feature = "provider-store")] +#[test] +fn redb_seal_is_atomic_idempotent_and_survives_deep_validated_reopen() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider-sealed.redb"); + let signer = Signer(SecretKey::from_bytes(&[0x87; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x88); + let first = checkpoint_bundle(0x89); + let second = continuation_bundle(&first); + let account_id = second + .verified_checkpoint() + .checkpoint() + .body() + .account_id(); + let second_id = second.verified_checkpoint().checkpoint_id(); + let (authorization, recovery, inventory) = { + let store = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + for (bundle, observed_at) in [(&first, 60_u64), (&second, 61_u64)] { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(observed_at), + &signer, + ) + .unwrap(); + } + let recovery = recovery_export(store.export_generation().unwrap()); + let inventory = derive_provider_retention_inventory(&recovery).unwrap(); + let authorization = verify_provider_compaction(&recovery, &recovery, &inventory).unwrap(); + assert_eq!( + store + .seal_after_verified_mirror(&authorization, &recovery, &inventory) + .unwrap(), + 1 + ); + (authorization, recovery, inventory) + }; + + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 2); + assert_eq!( + reopened.latest_checkpoint_bundle(account_id), + Err(IdentityError::ProviderArchiveRequired) + ); + let retained = reopened + .latest_retained_checkpoint_evidence(account_id) + .unwrap() + .unwrap(); + assert_eq!(retained.checkpoint().checkpoint_id().unwrap(), second_id); + assert_eq!(retained.receipt().leaf_index(), 1); + reopened.consistency_proof(1, 2).unwrap(); + assert_eq!( + reopened + .seal_after_verified_mirror(&authorization, &recovery, &inventory) + .unwrap(), + 1 + ); + assert_eq!( + reopened.account_history(account_id, None, 2, 4 * 1024 * 1024), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + reopened.export_generation(), + Err(IdentityError::ProviderArchiveRequired) + ); + let admission = first.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + assert_eq!( + reopened.append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(64), + &signer, + ), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + reopened.record_compaction_manifest(&authorization, &recovery, &inventory), + Err(IdentityError::ProviderArchiveRequired) + ); +} + +#[cfg(feature = "provider-store")] +#[test] +fn sealed_reopen_rejects_changed_outer_checkpoint_approval_bytes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory + .path() + .join("provider-sealed-approval-corruption.redb"); + let signer = Signer(SecretKey::from_bytes(&[0x8d; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x8e); + let first = checkpoint_bundle(0x8f); + let second = continuation_bundle(&first); + let approval_bytes = second + .verified_checkpoint() + .checkpoint() + .authorization() + .controller_approvals() + .unwrap() + .as_slice()[0] + .signatures()[0] + .signature() + .as_bytes() + .to_vec(); + { + let store = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + for (bundle, observed_at) in [(&first, 62_u64), (&second, 63_u64)] { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(observed_at), + &signer, + ) + .unwrap(); + } + let recovery = recovery_export(store.export_generation().unwrap()); + let inventory = derive_provider_retention_inventory(&recovery).unwrap(); + let authorization = verify_provider_compaction(&recovery, &recovery, &inventory).unwrap(); + store + .seal_after_verified_mirror(&authorization, &recovery, &inventory) + .unwrap(); + } + corrupt_unique_committed_subsequence(&path, &approval_bytes); + assert!(matches!( + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS,), + Err(IdentityError::StorageCorruption) + )); +} + +#[cfg(feature = "provider-store")] +#[test] +fn composite_recovery_archives_round_trip_full_history_and_remain_read_only() { + let directory = tempfile::tempdir().unwrap(); + let archive_path = directory.path().join("provider-recovery-archive.redb"); + let signer = Signer(SecretKey::from_bytes(&[0x8a; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x8b); + let active = + MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS).unwrap(); + let first = checkpoint_bundle(0x8c); + let second = continuation_bundle(&first); + for (bundle, observed_at) in [(&first, 70_u64), (&second, 71_u64)] { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + active + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(observed_at), + &signer, + ) + .unwrap(); + } + let recovery = recovery_export_with_attacks(active.export_generation().unwrap(), &signer); + assert_eq!(recovery.audit().records().len(), 3); + assert_eq!(recovery.artifacts().len(), 2); + + let archive = MemoryProviderStore::restore_recovery(recovery.clone()).unwrap(); + assert_eq!(archive.archived_recovery_export().unwrap(), recovery); + assert_eq!( + archive.archived_audit_snapshot().unwrap(), + *recovery.audit() + ); + assert_eq!( + archive.retained_audit_artifacts().unwrap(), + recovery.artifacts() + ); + assert_eq!(archive.export_generation().unwrap(), *recovery.generation()); + let account_id = second + .verified_checkpoint() + .checkpoint() + .body() + .account_id(); + let history = archive + .account_history(account_id, None, 8, 4 * 1024 * 1024) + .unwrap(); + assert_eq!(history.records().len(), 2); + assert!( + archive + .checkpoint_bundle(account_id, first.verified_checkpoint().checkpoint_id()) + .unwrap() + .is_some() + ); + let lineage = archive + .checkpoint_lineage_page( + account_id, + second.verified_checkpoint().checkpoint_id(), + 8, + 4 * 1024 * 1024, + ) + .unwrap() + .unwrap(); + assert_eq!(lineage.checkpoints().len(), 2); + let admission = first.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + assert_eq!( + archive.append( + authorize_provider_append(admission.clone(), request, &Allow).unwrap(), + Timestamp::from_unix_millis(72), + &signer, + ), + Err(IdentityError::ProviderArchiveRequired) + ); + + { + let redb = RedbProviderStore::restore_recovery(&archive_path, recovery.clone()).unwrap(); + assert_eq!(redb.archived_recovery_export().unwrap(), recovery); + assert_eq!(redb.archived_audit_snapshot().unwrap(), *recovery.audit()); + assert_eq!( + redb.retained_audit_artifacts().unwrap(), + recovery.artifacts() + ); + assert_eq!( + redb.append( + authorize_provider_append(admission.clone(), request, &Allow).unwrap(), + Timestamp::from_unix_millis(73), + &signer, + ), + Err(IdentityError::ProviderArchiveRequired) + ); + } + let reopened = + RedbProviderStore::open(&archive_path, provider, log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + assert_eq!(reopened.archived_recovery_export().unwrap(), recovery); + drop(reopened); + RedbProviderStore::restore_recovery(&archive_path, recovery).unwrap(); +} + +#[cfg(feature = "provider-store")] +#[test] +fn independently_addressed_generations_route_without_an_implicit_winner() { + let directory = tempfile::tempdir().unwrap(); + let old_path = directory.path().join("provider-old-generation.redb"); + let new_path = directory.path().join("provider-new-generation.redb"); + let archive_path = directory.path().join("provider-old-archive.redb"); + let old_signer = Signer(SecretKey::from_bytes(&[0xa1; 32])); + let new_signer = Signer(SecretKey::from_bytes(&[0xa2; 32])); + let old_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*old_signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let new_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*new_signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + assert_ne!(old_provider.id().unwrap(), new_provider.id().unwrap()); + let old_log_id = typed_id::(0xa3); + let new_log_id = typed_id::(0xa4); + let old_store = RedbProviderStore::open( + &old_path, + old_provider.clone(), + old_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let new_store = RedbProviderStore::open( + &new_path, + new_provider.clone(), + new_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let old_prior_checkpoint = checkpoint_bundle(0xa5); + let old_prior_admission = old_prior_checkpoint.provider_log_admission(); + let old_prior_request = ProviderAdmissionRequest::for_admission(&old_prior_admission).unwrap(); + old_store + .append( + authorize_provider_append(old_prior_admission, old_prior_request, &Allow).unwrap(), + Timestamp::from_unix_millis(80), + &old_signer, + ) + .unwrap(); + let old_checkpoint = continuation_bundle(&old_prior_checkpoint); + let old_admission = old_checkpoint.provider_log_admission(); + let old_request = ProviderAdmissionRequest::for_admission(&old_admission).unwrap(); + let old_receipt = old_store + .append( + authorize_provider_append(old_admission.clone(), old_request, &Allow).unwrap(), + Timestamp::from_unix_millis(81), + &old_signer, + ) + .unwrap(); + let old_recovery = recovery_export(old_store.export_generation().unwrap()); + let old_inventory = derive_provider_retention_inventory(&old_recovery).unwrap(); + let old_authorization = + verify_provider_compaction(&old_recovery, &old_recovery, &old_inventory).unwrap(); + old_store + .seal_after_verified_mirror(&old_authorization, &old_recovery, &old_inventory) + .unwrap(); + + let new_checkpoint = checkpoint_bundle(0xa6); + let new_admission = new_checkpoint.provider_log_admission(); + let new_request = ProviderAdmissionRequest::for_admission(&new_admission).unwrap(); + let new_receipt = new_store + .append( + authorize_provider_append(new_admission, new_request, &Allow).unwrap(), + Timestamp::from_unix_millis(82), + &new_signer, + ) + .unwrap(); + assert!(old_receipt.verify(&new_provider).is_err()); + assert!(new_receipt.verify(&old_provider).is_err()); + + let old_route = old_store.generation_route().unwrap(); + let new_route = new_store.generation_route().unwrap(); + assert_eq!(old_route.provider_id(), old_provider.id().unwrap()); + assert_eq!(old_route.log_id(), old_log_id); + assert_eq!(old_route.key_version(), ProviderKeyVersion::GENESIS); + assert_eq!(new_route.provider_id(), new_provider.id().unwrap()); + assert_eq!(new_route.log_id(), new_log_id); + let mut registry = ProviderGenerationRegistry::new(); + registry.insert(old_store.clone()).unwrap(); + registry.insert(new_store.clone()).unwrap(); + assert_eq!(registry.len(), 2); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![new_provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + assert_eq!( + registry + .for_policy(&policy, new_route) + .unwrap() + .snapshot() + .unwrap() + .tree_size(), + 1 + ); + assert!(matches!( + registry.for_policy(&policy, old_route), + Err(IdentityError::InvalidRelationship { + resource: "provider generation account policy", + }) + )); + let cross_route = + ProviderGenerationRoute::new(&new_provider, old_log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + assert!(matches!( + registry.require(cross_route), + Err(IdentityError::InvalidRelationship { + resource: "provider generation route", + }) + )); + let old_account = old_checkpoint + .verified_checkpoint() + .checkpoint() + .body() + .account_id(); + let retained_evidence = old_store + .latest_retained_checkpoint_evidence(old_account) + .unwrap() + .unwrap(); + assert!(retained_evidence.genesis().is_none()); + assert_eq!( + retained_evidence.prior_checkpoint_id(), + Some(old_prior_checkpoint.verified_checkpoint().checkpoint_id()) + ); + let mut old_prior_state = + AccountState::from_genesis(old_prior_checkpoint.genesis().unwrap()).unwrap(); + for event in old_prior_checkpoint.events() { + old_prior_state.validate_and_apply(event).unwrap(); + } + let reconstructed_from_raw = build_provider_checkpoint_bundle_from_prior( + &old_prior_state, + old_prior_checkpoint.verified_checkpoint(), + retained_evidence.events(), + retained_evidence.checkpoint(), + retained_evidence.transition_event(), + ) + .unwrap(); + assert_eq!(reconstructed_from_raw, old_checkpoint); + assert_eq!( + old_store.export_generation(), + Err(IdentityError::ProviderArchiveRequired) + ); + + let archive = RedbProviderStore::restore_recovery(&archive_path, old_recovery.clone()).unwrap(); + assert_eq!(archive.archived_recovery_export().unwrap(), old_recovery); + assert!(matches!( + registry.insert(archive.clone()), + Err(IdentityError::DuplicateElement { + resource: "provider generation route", + }) + )); + assert_eq!(registry.len(), 2); + let mut archive_first_registry = ProviderGenerationRegistry::new(); + archive_first_registry.insert(archive.clone()).unwrap(); + assert!(matches!( + archive_first_registry.insert(old_store.clone()), + Err(IdentityError::DuplicateElement { + resource: "provider generation route", + }) + )); + assert_eq!(archive_first_registry.len(), 1); + assert!( + archive + .checkpoint_bundle( + old_account, + old_checkpoint.verified_checkpoint().checkpoint_id(), + ) + .unwrap() + .is_some() + ); + let raw_admission = reconstructed_from_raw.provider_log_admission(); + let raw_request = ProviderAdmissionRequest::for_admission(&raw_admission).unwrap(); + let archive_before_raw_append = archive.archived_recovery_export().unwrap(); + assert_eq!( + archive.append( + authorize_provider_append(raw_admission, raw_request, &Allow).unwrap(), + Timestamp::from_unix_millis(83), + &old_signer, + ), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + archive.archived_recovery_export().unwrap(), + archive_before_raw_append + ); + assert_eq!( + archive.resume_append(&old_signer), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + archive.cancel_prepared_append(), + Err(IdentityError::ProviderArchiveRequired) + ); + assert_eq!( + archive.seal_after_verified_mirror(&old_authorization, &old_recovery, &old_inventory,), + Err(IdentityError::ProviderArchiveRequired) + ); + + drop(registry); + drop(archive_first_registry); + drop(archive); + drop(old_store); + drop(new_store); + RedbProviderStore::restore_recovery(&archive_path, old_recovery.clone()).unwrap(); + let conflicting_recovery = recovery_export( + MemoryProviderStore::new( + old_provider.clone(), + old_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap() + .export_generation() + .unwrap(), + ); + assert!(matches!( + RedbProviderStore::restore_recovery(&archive_path, conflicting_recovery), + Err(IdentityError::InvalidRelationship { + resource: "provider recovery archive destination", + }) + )); + assert!(matches!( + RedbProviderStore::open( + &old_path, + new_provider.clone(), + new_log_id, + ProviderKeyVersion::GENESIS, + ), + Err(IdentityError::InvalidRelationship { + resource: "provider store generation", + }) + )); + let old_reopened = RedbProviderStore::open( + &old_path, + old_provider.clone(), + old_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let new_reopened = RedbProviderStore::open( + &new_path, + new_provider, + new_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let archive_reopened = RedbProviderStore::open( + &archive_path, + old_provider, + old_log_id, + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + assert_eq!(old_reopened.snapshot().unwrap().tree_size(), 2); + assert_eq!(new_reopened.snapshot().unwrap().tree_size(), 1); + assert_eq!( + archive_reopened.archived_recovery_export().unwrap(), + old_recovery + ); +} + +#[cfg(feature = "provider-store")] +#[test] +fn concurrent_redb_appends_are_linearizable_and_duplicate_idempotent() { + const WRITERS: usize = 4; + const MAX_ATTEMPTS: usize = 2_000; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider-concurrent.redb"); + let signer_fill = 0xb1; + let signer = Signer(SecretKey::from_bytes(&[signer_fill; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xb2); + let store = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + let barrier = Arc::new(Barrier::new(WRITERS)); + let mut threads = Vec::new(); + for writer in 0..WRITERS { + let store = store.clone(); + let barrier = barrier.clone(); + threads.push(thread::spawn(move || { + let bundle = checkpoint_bundle(0xd0_u8.saturating_add(u8::try_from(writer).unwrap())); + let checkpoint_id = bundle.verified_checkpoint().checkpoint_id(); + let observed_at = Timestamp::from_unix_millis(120); + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + let signer = Signer(SecretKey::from_bytes(&[signer_fill; 32])); + barrier.wait(); + for _ in 0..MAX_ATTEMPTS { + let permit = authorize_provider_append(admission.clone(), request, &Allow).unwrap(); + match store.append(permit, observed_at, &signer) { + Ok(receipt) => return (checkpoint_id, receipt), + Err(IdentityError::ResourceBusy) => thread::yield_now(), + Err(error) => panic!("unexpected concurrent append failure: {error:?}"), + } + } + panic!("concurrent append retry bound exhausted"); + })); + } + let mut leaf_indices = BTreeSet::new(); + let mut checkpoint_ids = BTreeSet::new(); + let writer_count = u64::try_from(WRITERS).unwrap(); + let duplicate_checkpoint_id = checkpoint_bundle(0xd0) + .verified_checkpoint() + .checkpoint_id(); + let mut duplicate_leaf_index = None; + for handle in threads { + let (checkpoint_id, receipt) = handle.join().unwrap(); + receipt.verify(&provider).unwrap(); + if checkpoint_id == duplicate_checkpoint_id { + duplicate_leaf_index = Some(receipt.leaf_index()); + } + checkpoint_ids.insert(checkpoint_id); + leaf_indices.insert(receipt.leaf_index()); + } + assert_eq!(checkpoint_ids.len(), WRITERS); + assert_eq!(leaf_indices, (0..writer_count).collect()); + assert_eq!(store.snapshot().unwrap().tree_size(), writer_count); + let duplicate_leaf_index = duplicate_leaf_index.unwrap(); + + let duplicate_barrier = Arc::new(Barrier::new(WRITERS)); + let mut duplicate_threads = Vec::new(); + for _ in 0..WRITERS { + let store = store.clone(); + let barrier = duplicate_barrier.clone(); + duplicate_threads.push(thread::spawn(move || { + let observed_at = Timestamp::from_unix_millis(120); + let admission = checkpoint_bundle(0xd0).provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + let signer = Signer(SecretKey::from_bytes(&[signer_fill; 32])); + barrier.wait(); + for _ in 0..MAX_ATTEMPTS { + let permit = authorize_provider_append(admission.clone(), request, &Allow).unwrap(); + match store.append(permit, observed_at, &signer) { + Ok(receipt) => return receipt, + Err(IdentityError::ResourceBusy) => thread::yield_now(), + Err(error) => panic!("unexpected duplicate append failure: {error:?}"), + } + } + panic!("duplicate append retry bound exhausted"); + })); + } + for handle in duplicate_threads { + assert_eq!(handle.join().unwrap().leaf_index(), duplicate_leaf_index); + } + let export = store.export_generation().unwrap(); + assert_eq!(export.entries().len(), WRITERS); + assert_eq!(export.receipts().len(), WRITERS); + assert_eq!(store.snapshot().unwrap().tree_size(), writer_count); + drop(store); + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!(reopened.export_generation().unwrap(), export); +} + +#[test] +fn provider_checkpoint_index_rejects_rollback_and_retains_asymmetric_fork() { + let signer = Signer(SecretKey::from_bytes(&[0x91; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let store = MemoryProviderStore::new( + provider.clone(), + typed_id::(0x92), + ProviderKeyVersion::GENESIS, + ) + .unwrap(); + let current = checkpoint_bundle_at(0x93, 1, 0xa0, 2_000); + let refreshed = checkpoint_bundle_at(0x93, 1, 0xa0, 2_001); + let conflict = checkpoint_bundle_at(0x93, 2, 0xb0, 2_002); + let longer_conflict = checkpoint_bundle_at(0x93, 3, 0xb0, 2_003); + let lower = checkpoint_bundle_at(0x93, 1, 0xa0, 2_004); + let account_id = current + .verified_checkpoint() + .checkpoint() + .body() + .account_id(); + let current_admission = current.provider_log_admission(); + let current_request = ProviderAdmissionRequest::for_admission(¤t_admission).unwrap(); + + store + .append( + authorize_provider_append(current_admission, current_request, &Allow).unwrap(), + Timestamp::from_unix_millis(40), + &signer, + ) + .unwrap(); + store + .append( + { + let admission = refreshed.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + authorize_provider_append(admission, request, &Allow).unwrap() + }, + Timestamp::from_unix_millis(41), + &signer, + ) + .unwrap(); + assert_eq!( + store.latest_checkpoint_bundle(account_id).unwrap().unwrap(), + refreshed + ); + + store + .append( + { + let admission = conflict.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + authorize_provider_append(admission, request, &Allow).unwrap() + }, + Timestamp::from_unix_millis(42), + &signer, + ) + .unwrap(); + assert_eq!( + store.latest_checkpoint_bundle(account_id), + Err(IdentityError::AccountForked) + ); + let conflict_id = conflict.verified_checkpoint().checkpoint_id(); + let explicit_conflict = store + .checkpoint_bundle(account_id, conflict_id) + .unwrap() + .unwrap(); + assert_eq!( + explicit_conflict + .bundle() + .verified_checkpoint() + .checkpoint_id(), + conflict_id + ); + explicit_conflict.receipt().verify(&provider).unwrap(); + let exact_page = store + .checkpoint_lineage_page(account_id, conflict_id, 1, 4 * 1024 * 1024) + .unwrap() + .unwrap(); + assert_eq!(exact_page.checkpoints().len(), 1); + assert_eq!(exact_page.next_prior_checkpoint_id(), None); + store + .append( + { + let admission = longer_conflict.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + authorize_provider_append(admission, request, &Allow).unwrap() + }, + Timestamp::from_unix_millis(43), + &signer, + ) + .unwrap(); + assert_eq!(store.snapshot().unwrap().tree_size(), 4); + assert_eq!( + store.latest_checkpoint_bundle(account_id), + Err(IdentityError::AccountForked) + ); + assert_eq!( + store.append( + { + let admission = lower.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + authorize_provider_append(admission, request, &Allow).unwrap() + }, + Timestamp::from_unix_millis(44), + &signer, + ), + Err(IdentityError::ProviderRollback) + ); + assert_eq!(store.snapshot().unwrap().tree_size(), 4); + let restored = + MemoryProviderStore::restore_generation(store.export_generation().unwrap()).unwrap(); + assert_eq!( + restored.latest_checkpoint_bundle(account_id), + Err(IdentityError::AccountForked) + ); +} + +#[cfg(feature = "provider-store")] +#[test] +fn redb_reopen_retains_signing_candidate_and_resumes_exactly() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider.redb"); + let signer = Signer(SecretKey::from_bytes(&[0x61; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x62); + let checkpoint = checkpoint_bundle(0x63); + let admission = checkpoint.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + + { + let store = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + let permit = authorize_provider_append(admission.clone(), request, &Allow).unwrap(); + assert_eq!( + store.append(permit, Timestamp::from_unix_millis(20), &UnavailableSigner,), + Err(IdentityError::ProviderUnavailable) + ); + assert_eq!(store.snapshot().unwrap().tree_size(), 0); + } + + let store = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + assert_eq!(store.snapshot().unwrap().tree_size(), 0); + let receipt = store.resume_append(&signer).unwrap(); + receipt.verify(&provider).unwrap(); + let export = store.export_generation().unwrap(); + let mirror = MemoryProviderStore::restore_generation(export.clone()).unwrap(); + let mirror_export = mirror.export_generation().unwrap(); + let recovery = recovery_export(export); + let mirror_recovery = recovery_export(mirror_export); + let inventory = derive_provider_retention_inventory(&recovery).unwrap(); + let authorization = + verify_provider_compaction(&recovery, &mirror_recovery, &inventory).unwrap(); + store + .record_compaction_manifest(&authorization, &mirror_recovery, &inventory) + .unwrap(); + let expected = store.snapshot().unwrap(); + drop(store); + + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!(reopened.snapshot().unwrap(), expected); + assert_eq!( + reopened.compaction_manifests().unwrap(), + vec![authorization.manifest().clone()] + ); + let page = reopened + .account_history(admission.account_id(), None, 1, 4 * 1024 * 1024) + .unwrap(); + assert_eq!(page.records().len(), 1); + assert_eq!( + reopened + .latest_checkpoint_bundle(admission.account_id()) + .unwrap() + .unwrap(), + checkpoint + ); + reopened.consistency_proof(0, 1).unwrap(); +} + +#[cfg(feature = "provider-store")] +#[test] +fn redb_checkpoint_index_reopens_without_selecting_a_longer_fork() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider-fork-index.redb"); + let signer = Signer(SecretKey::from_bytes(&[0xb1; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0xb2); + let current = checkpoint_bundle_at(0xb3, 1, 0xc0, 3_000); + let refreshed = checkpoint_bundle_at(0xb3, 1, 0xc0, 3_001); + let conflict = checkpoint_bundle_at(0xb3, 2, 0xd0, 3_002); + let longer_conflict = checkpoint_bundle_at(0xb3, 3, 0xd0, 3_003); + let lower = checkpoint_bundle_at(0xb3, 1, 0xc0, 3_004); + let account_id = current + .verified_checkpoint() + .checkpoint() + .body() + .account_id(); + + { + let store = + RedbProviderStore::open(&path, provider.clone(), log_id, ProviderKeyVersion::GENESIS) + .unwrap(); + for (offset, bundle) in [current, refreshed, conflict, longer_conflict] + .into_iter() + .enumerate() + { + let admission = bundle.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(50 + u64::try_from(offset).unwrap()), + &signer, + ) + .unwrap(); + } + assert_eq!(store.snapshot().unwrap().tree_size(), 4); + assert_eq!( + store.latest_checkpoint_bundle(account_id), + Err(IdentityError::AccountForked) + ); + } + + let reopened = + RedbProviderStore::open(&path, provider, log_id, ProviderKeyVersion::GENESIS).unwrap(); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 4); + assert_eq!( + reopened.latest_checkpoint_bundle(account_id), + Err(IdentityError::AccountForked) + ); + let admission = lower.provider_log_admission(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + assert_eq!( + reopened.append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(55), + &signer, + ), + Err(IdentityError::ProviderRollback) + ); + assert_eq!(reopened.snapshot().unwrap().tree_size(), 4); +} + +#[test] +fn auditor_retains_authenticated_rollback_and_equivocation_across_instances() { + let signer = Signer(SecretKey::from_bytes(&[0x71; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x72); + let store = MemoryProviderAuditStore::new(provider.clone(), log_id); + let auditor = DurableProviderAuditor::new(store.clone()); + let first = signed_head(&provider, log_id, 2, 0x73, 30, &signer); + + assert_eq!( + auditor.observe(first.clone(), None).unwrap(), + ProviderHeadAuditDisposition::FirstObserved + ); + let rollback = signed_head(&provider, log_id, 1, 0x74, 31, &signer); + assert_eq!( + auditor.observe(rollback, None), + Err(IdentityError::ProviderRollback) + ); + assert_eq!( + store.snapshot().unwrap().records().last().unwrap().status(), + ProviderAuditStatus::Rollback + ); + + let conflict = signed_head(&provider, log_id, 2, 0x75, 32, &signer); + assert_eq!( + auditor.observe(conflict, None), + Err(IdentityError::ProviderEquivocation) + ); + let retained = store.snapshot().unwrap(); + retained + .equivocation_evidence() + .unwrap() + .verify(&provider) + .unwrap(); + + let reopened = DurableProviderAuditor::new(store); + assert_eq!( + reopened.observe(first, None), + Err(IdentityError::ProviderEquivocation) + ); +} + +#[cfg(feature = "provider-store")] +#[test] +fn redb_auditor_reopens_with_terminal_equivocation_evidence() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("provider-audit.redb"); + let signer = Signer(SecretKey::from_bytes(&[0x81; 32])); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*signer.0.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let log_id = typed_id::(0x82); + let first = signed_head(&provider, log_id, 3, 0x83, 40, &signer); + let conflict = signed_head(&provider, log_id, 3, 0x84, 41, &signer); + { + let store = RedbProviderAuditStore::open(&path, provider.clone(), log_id).unwrap(); + let auditor = DurableProviderAuditor::new(store); + auditor.observe(first, None).unwrap(); + assert_eq!( + auditor.observe(conflict, None), + Err(IdentityError::ProviderEquivocation) + ); + } + + let reopened = RedbProviderAuditStore::open(&path, provider.clone(), log_id).unwrap(); + let evidence = reopened + .snapshot() + .unwrap() + .equivocation_evidence() + .cloned() + .unwrap(); + evidence.verify(&provider).unwrap(); +} diff --git a/protocols/krikos-identity/tests/provider_wire_formats.rs b/protocols/krikos-identity/tests/provider_wire_formats.rs new file mode 100644 index 00000000000..37901d5ba24 --- /dev/null +++ b/protocols/krikos-identity/tests/provider_wire_formats.rs @@ -0,0 +1,602 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, AlgorithmSignature, CanonicalWire, + ControlPolicy, ControllerClass, ControllerDescriptor, ControllerKeyId, ControllerScope, + ControllerSelector, ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, Digest, + DurableProviderAuditor, DurationMillis, EventBody, EventIntentApprovalBody, + EventIntentApprovals, EventPredecessors, Extensions, FreshnessRequirement, HashAlgorithm, + IdentityError, KeyedSignature, MAX_PROVIDER_EXPORT_CHUNK_BYTES, + MAX_PROVIDER_EXPORT_CHUNK_ITEMS, MAX_PROVIDER_EXPORT_ITEM_BYTES, MemoryProviderAuditStore, + MemoryProviderStore, OperationKind, PolicyRule, ProtocolSignature, ProviderAdmissionControl, + ProviderAdmissionRequest, ProviderAuditExportAssembler, ProviderAuditExportChunk, + ProviderAuditExportManifest, ProviderAuditSnapshot, ProviderDescriptor, + ProviderEquivocationEvidence, ProviderExportComponent, ProviderExportComponentDescriptor, + ProviderGenerationExport, ProviderGenerationExportAssembler, ProviderGenerationExportChunk, + ProviderGenerationExportManifest, ProviderHeadSigner, ProviderId, ProviderKeyVersion, + ProviderLogId, ProviderPolicy, ProviderPolicyVersion, ProviderQuorum, ProviderRecoveryExport, + ProviderRecoveryExportManifest, RecoveryAuthority, RecoveryPolicy, RecoveryPolicyVersion, + RequiredWeight, SignedEventIntentApproval, SignedProviderHead, SigningPublicKey, Timestamp, + authorize_provider_append, limits::MAX_MERKLE_LOG_LEAVES, verify_event_intent_admission, +}; +use serde::{Deserialize, Serialize}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +struct Allow; + +impl ProviderAdmissionControl for Allow { + fn check( + &self, + _admission: krikos_identity::ProviderLogAdmission, + _request: ProviderAdmissionRequest, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +struct Signer(SecretKey); + +impl ProviderHeadSigner for Signer { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +fn intent_approval( + controller_id: krikos_identity::ControllerId, + proposal_id: krikos_identity::ProposalId, + signer: &SecretKey, +) -> SignedEventIntentApproval { + let body = + EventIntentApprovalBody::new(controller_id, proposal_id, Extensions::default()).unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let signature = signer.sign(&body.to_canonical_bytes().unwrap()); + SignedEventIntentApproval::new( + body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap() +} + +fn populated_fixture( + entry_count: usize, + provider_fill: u8, + log_fill: u8, +) -> ( + ProviderGenerationExport, + ProviderAuditSnapshot, + ProviderRecoveryExport, +) { + let provider_secret = SecretKey::from_bytes(&[provider_fill; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let provider_policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let controller_secret = SecretKey::from_bytes(&[0x41; 32]); + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + Some(DurationMillis::new(1)), + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x51; 32], + Timestamp::from_unix_millis(1), + policy, + vec![controller(&controller_secret)], + recovery, + provider_policy, + Extensions::default(), + ) + .unwrap(); + let account = AccountState::from_genesis(&genesis).unwrap(); + let controller_id = account.active_controllers()[0].id(); + let operation = + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x52; 32]))); + let log_id = typed_id::(log_fill); + let store = + MemoryProviderStore::new(provider.clone(), log_id, ProviderKeyVersion::GENESIS).unwrap(); + let signer = Signer(provider_secret); + let audit_store = MemoryProviderAuditStore::new(provider.clone(), log_id); + let auditor = DurableProviderAuditor::new(audit_store); + + for index in 0..entry_count { + let index = u64::try_from(index).unwrap(); + let nonce_value = index.checked_add(1).unwrap(); + let nonce: [u8; 16] = nonce_value.to_le_bytes().repeat(2).try_into().unwrap(); + let body = EventBody::new( + account.account_id(), + account.sequence().checked_next().unwrap(), + account.expected_epoch_for(&operation).unwrap(), + EventPredecessors::genesis(account.genesis_anchor()), + operation.clone(), + Timestamp::from_unix_millis(index.saturating_add(2)), + nonce, + Extensions::default(), + ) + .unwrap(); + let proposal_id = body.proposal_id().unwrap(); + let approvals = EventIntentApprovals::new(vec![intent_approval( + controller_id, + proposal_id, + &controller_secret, + )]) + .unwrap(); + let admission = verify_event_intent_admission(&account, &body, &approvals).unwrap(); + let request = ProviderAdmissionRequest::for_admission(&admission).unwrap(); + store + .append( + authorize_provider_append(admission, request, &Allow).unwrap(), + Timestamp::from_unix_millis(index.saturating_add(10_000)), + &signer, + ) + .unwrap(); + let snapshot = store.snapshot().unwrap(); + let consistency_proof = if index == 0 { + None + } else { + Some(store.consistency_proof(index, index + 1).unwrap()) + }; + auditor + .observe( + snapshot.latest_head().unwrap().clone(), + consistency_proof.as_ref(), + ) + .unwrap(); + } + + let generation = store.export_generation().unwrap(); + let audit = auditor.snapshot().unwrap(); + let recovery = ProviderRecoveryExport::new(generation.clone(), audit.clone()).unwrap(); + (generation, audit, recovery) +} + +fn assert_canonical(value: &T) { + let bytes = value.to_canonical_bytes().unwrap(); + assert_eq!(T::from_canonical_bytes(&bytes).unwrap(), *value); +} + +#[derive(Debug, Serialize, Deserialize)] +struct DescriptorMirror { + format_version: u16, + component_code: u16, + item_count: u64, + chunk_count: u32, + total_payload_bytes: u64, + chunk_list_commitment: Digest, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GenerationManifestMirror { + format_version: u16, + provider: ProviderDescriptor, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + tree_size: u64, + tree_root: Digest, + latest_head: Option, + generation_commitment: Digest, + total_payload_bytes: u64, + components: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GenerationChunkMirror { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + key_version: ProviderKeyVersion, + generation_commitment: Digest, + component_code: u16, + ordinal: u32, + start_index: u64, + end_index: u64, + item_payload_bytes: u64, + payload: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AuditManifestMirror { + format_version: u16, + provider: ProviderDescriptor, + log_id: ProviderLogId, + latest_head: Option, + equivocation: Option, + record_count: u64, + chunk_count: u32, + total_payload_bytes: u64, + audit_commitment: Digest, + artifact_count: u64, + artifact_commitment: Digest, + chunk_list_commitment: Digest, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AuditChunkMirror { + format_version: u16, + provider_id: ProviderId, + log_id: ProviderLogId, + audit_commitment: Digest, + ordinal: u32, + start_sequence: u64, + end_sequence: u64, + item_payload_bytes: u64, + payload: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct RecoveryManifestMirror { + format_version: u16, + generation: ProviderGenerationExportManifest, + audit: ProviderAuditExportManifest, + generation_manifest_commitment: Digest, + audit_manifest_commitment: Digest, + generation_commitment: Digest, + audit_commitment: Digest, + artifact_commitment: Digest, + recovery_commitment: Digest, +} + +fn generation_manifest_mirror( + manifest: &ProviderGenerationExportManifest, +) -> GenerationManifestMirror { + postcard::from_bytes(&manifest.to_canonical_bytes().unwrap()).unwrap() +} + +fn generation_manifest_from_mirror( + mirror: &GenerationManifestMirror, +) -> Result { + ProviderGenerationExportManifest::from_canonical_bytes(&postcard::to_stdvec(mirror).unwrap()) +} + +fn generation_chunk_mirror(chunk: &ProviderGenerationExportChunk) -> GenerationChunkMirror { + postcard::from_bytes(&chunk.to_canonical_bytes().unwrap()).unwrap() +} + +fn generation_chunk_from_mirror( + mirror: &GenerationChunkMirror, +) -> Result { + ProviderGenerationExportChunk::from_canonical_bytes(&postcard::to_stdvec(mirror).unwrap()) +} + +fn audit_manifest_mirror(manifest: &ProviderAuditExportManifest) -> AuditManifestMirror { + postcard::from_bytes(&manifest.to_canonical_bytes().unwrap()).unwrap() +} + +fn audit_manifest_from_mirror( + mirror: &AuditManifestMirror, +) -> Result { + ProviderAuditExportManifest::from_canonical_bytes(&postcard::to_stdvec(mirror).unwrap()) +} + +fn audit_chunk_mirror(chunk: &ProviderAuditExportChunk) -> AuditChunkMirror { + postcard::from_bytes(&chunk.to_canonical_bytes().unwrap()).unwrap() +} + +fn audit_chunk_from_mirror( + mirror: &AuditChunkMirror, +) -> Result { + ProviderAuditExportChunk::from_canonical_bytes(&postcard::to_stdvec(mirror).unwrap()) +} + +fn recovery_manifest_mirror(manifest: &ProviderRecoveryExportManifest) -> RecoveryManifestMirror { + postcard::from_bytes(&manifest.to_canonical_bytes().unwrap()).unwrap() +} + +fn recovery_manifest_from_mirror( + mirror: &RecoveryManifestMirror, +) -> Result { + ProviderRecoveryExportManifest::from_canonical_bytes(&postcard::to_stdvec(mirror).unwrap()) +} + +#[test] +fn provider_interchange_boundaries_are_canonical_and_fail_closed() { + let (generation, audit, recovery) = populated_fixture(1, 0x61, 0x62); + let (generation_manifest, generation_chunks) = generation.interchange_parts().unwrap(); + let (audit_manifest, audit_chunks) = audit.interchange_parts().unwrap(); + let (recovery_manifest, _, _) = recovery.interchange_parts().unwrap(); + + assert_canonical(&ProviderExportComponent::Entries); + assert_canonical(&generation_manifest); + assert_canonical(&audit_manifest); + assert_canonical(&recovery_manifest); + let mut wrong_recovery_version = recovery_manifest_mirror(&recovery_manifest); + wrong_recovery_version.format_version = 2; + assert!(recovery_manifest_from_mirror(&wrong_recovery_version).is_err()); + let mut wrong_recovery_commitment = recovery_manifest_mirror(&recovery_manifest); + wrong_recovery_commitment.recovery_commitment = + Digest::new(HashAlgorithm::Blake3_256, [0x66; 32]); + assert!(recovery_manifest_from_mirror(&wrong_recovery_commitment).is_err()); + for chunk in &generation_chunks { + assert_canonical(chunk); + assert!(chunk.to_canonical_bytes().unwrap().len() <= MAX_PROVIDER_EXPORT_CHUNK_BYTES); + } + for chunk in &audit_chunks { + assert_canonical(chunk); + assert!(chunk.to_canonical_bytes().unwrap().len() <= MAX_PROVIDER_EXPORT_CHUNK_BYTES); + } + + let mut trailing = generation_manifest.to_canonical_bytes().unwrap(); + trailing.push(0); + assert!(ProviderGenerationExportManifest::from_canonical_bytes(&trailing).is_err()); + + let mut wrong_version = generation_manifest_mirror(&generation_manifest); + wrong_version.format_version = 2; + assert!(generation_manifest_from_mirror(&wrong_version).is_err()); + assert!( + ProviderExportComponent::from_canonical_bytes( + &postcard::to_stdvec(&(1_u16, 99_u16)).unwrap() + ) + .is_err() + ); + assert!(ProviderExportComponent::from_canonical_bytes(&[0x81, 0x00, 0x01]).is_err()); + let mut oversized_descriptor: DescriptorMirror = postcard::from_bytes( + &generation_manifest + .descriptor(ProviderExportComponent::Entries) + .unwrap() + .to_canonical_bytes() + .unwrap(), + ) + .unwrap(); + oversized_descriptor.item_count = u64::try_from(MAX_MERKLE_LOG_LEAVES + 1).unwrap(); + assert!( + ProviderExportComponentDescriptor::from_canonical_bytes( + &postcard::to_stdvec(&oversized_descriptor).unwrap() + ) + .is_err() + ); + assert!( + ProviderGenerationExportChunk::from_canonical_bytes(&vec![ + 0; + MAX_PROVIDER_EXPORT_CHUNK_BYTES + + 1 + ]) + .is_err() + ); + + let mut oversized_item = generation_chunk_mirror(&generation_chunks[0]); + oversized_item.start_index = 0; + oversized_item.end_index = 1; + oversized_item.item_payload_bytes = u64::try_from(MAX_PROVIDER_EXPORT_ITEM_BYTES + 1).unwrap(); + oversized_item.payload = + postcard::to_stdvec(&vec![vec![0_u8; MAX_PROVIDER_EXPORT_ITEM_BYTES + 1]]).unwrap(); + assert!(generation_chunk_from_mirror(&oversized_item).is_err()); + + let (empty_generation, _, _) = populated_fixture(0, 0x63, 0x64); + let (empty_manifest, empty_chunks) = empty_generation.interchange_parts().unwrap(); + assert!(empty_chunks.is_empty()); + let mut bad_empty_root = generation_manifest_mirror(&empty_manifest); + bad_empty_root.tree_root = Digest::new(HashAlgorithm::Blake3_256, [0x65; 32]); + assert!(generation_manifest_from_mirror(&bad_empty_root).is_err()); +} + +#[test] +fn provider_interchange_assembles_out_of_order_and_rejects_tampering() { + let (generation, audit, recovery_257) = + populated_fixture(MAX_PROVIDER_EXPORT_CHUNK_ITEMS + 1, 0x71, 0x72); + assert_eq!(recovery_257.audit().records().len(), 257); + let (manifest, chunks) = generation.interchange_parts().unwrap(); + let entries = manifest + .descriptor(ProviderExportComponent::Entries) + .unwrap(); + assert_eq!(entries.item_count(), 257); + assert_eq!(entries.chunk_count(), 2); + + let mut reversed = chunks.clone(); + reversed.reverse(); + let replay = reversed.remove(0); + let mut assembler = ProviderGenerationExportAssembler::new(manifest.clone()).unwrap(); + assert!(assembler.insert(replay.clone()).unwrap()); + assert!(!assembler.insert(replay).unwrap()); + for chunk in reversed { + assert!(assembler.insert(chunk).unwrap()); + } + assert_eq!(assembler.finish().unwrap(), generation); + + let mut incomplete = ProviderGenerationExportAssembler::new(manifest.clone()).unwrap(); + for chunk in chunks.iter().take(chunks.len() - 1).cloned() { + incomplete.insert(chunk).unwrap(); + } + assert!(incomplete.finish().is_err()); + + let entry_chunks = chunks + .iter() + .filter(|chunk| chunk.component().unwrap() == ProviderExportComponent::Entries) + .cloned() + .collect::>(); + let mut conflicting = generation_chunk_mirror(&entry_chunks[1]); + conflicting.ordinal = 0; + let conflicting = generation_chunk_from_mirror(&conflicting).unwrap(); + let mut conflict_assembler = ProviderGenerationExportAssembler::new(manifest.clone()).unwrap(); + conflict_assembler.insert(entry_chunks[0].clone()).unwrap(); + assert!(conflict_assembler.insert(conflicting).is_err()); + + let mut overlap = generation_chunk_mirror(&entry_chunks[1]); + overlap.start_index -= 1; + overlap.end_index -= 1; + let overlap = generation_chunk_from_mirror(&overlap).unwrap(); + let mut overlap_assembler = ProviderGenerationExportAssembler::new(manifest.clone()).unwrap(); + for chunk in chunks.iter().cloned() { + if chunk.component().unwrap() == ProviderExportComponent::Entries && chunk.ordinal() == 1 { + overlap_assembler.insert(overlap.clone()).unwrap(); + } else { + overlap_assembler.insert(chunk).unwrap(); + } + } + assert!(overlap_assembler.finish().is_err()); + + let mut gap = generation_chunk_mirror(&entry_chunks[0]); + gap.start_index += 1; + gap.end_index += 1; + let gap = generation_chunk_from_mirror(&gap).unwrap(); + let mut gap_assembler = ProviderGenerationExportAssembler::new(manifest.clone()).unwrap(); + for chunk in chunks.iter().cloned() { + if chunk.component().unwrap() == ProviderExportComponent::Entries && chunk.ordinal() == 0 { + gap_assembler.insert(gap.clone()).unwrap(); + } else { + gap_assembler.insert(chunk).unwrap(); + } + } + assert!(gap_assembler.finish().is_err()); + + let mut tampered_manifest = generation_manifest_mirror(&manifest); + let entry_descriptor = tampered_manifest + .components + .iter_mut() + .find(|descriptor| descriptor.component_code == ProviderExportComponent::Entries.code()) + .unwrap(); + entry_descriptor.chunk_list_commitment = Digest::new(HashAlgorithm::Blake3_256, [0x73; 32]); + let tampered_manifest = generation_manifest_from_mirror(&tampered_manifest).unwrap(); + let mut root_assembler = ProviderGenerationExportAssembler::new(tampered_manifest).unwrap(); + for chunk in chunks.iter().cloned() { + root_assembler.insert(chunk).unwrap(); + } + assert!(root_assembler.finish().is_err()); + + let (foreign_generation, foreign_audit, _) = populated_fixture(1, 0x74, 0x75); + let (_, foreign_chunks) = foreign_generation.interchange_parts().unwrap(); + let mut cross_manifest = ProviderGenerationExportAssembler::new(manifest.clone()).unwrap(); + assert!(cross_manifest.insert(foreign_chunks[0].clone()).is_err()); + + let (audit_manifest, audit_chunks) = audit.interchange_parts().unwrap(); + assert_eq!(audit_manifest.record_count(), 257); + assert_eq!(audit_manifest.chunk_count(), 2); + + let mut bad_payload_accounting = audit_chunk_mirror(&audit_chunks[0]); + bad_payload_accounting.item_payload_bytes += 1; + assert!(audit_chunk_from_mirror(&bad_payload_accounting).is_err()); + + let mut conflicting_audit = audit_chunk_mirror(&audit_chunks[1]); + conflicting_audit.ordinal = 0; + let conflicting_audit = audit_chunk_from_mirror(&conflicting_audit).unwrap(); + let mut audit_conflict = ProviderAuditExportAssembler::new(audit_manifest.clone()).unwrap(); + audit_conflict.insert(audit_chunks[0].clone()).unwrap(); + assert!(audit_conflict.insert(conflicting_audit).is_err()); + + let mut overlapping_audit = audit_chunk_mirror(&audit_chunks[1]); + overlapping_audit.start_sequence -= 1; + overlapping_audit.end_sequence -= 1; + let overlapping_audit = audit_chunk_from_mirror(&overlapping_audit).unwrap(); + let mut audit_overlap = ProviderAuditExportAssembler::new(audit_manifest.clone()).unwrap(); + audit_overlap.insert(audit_chunks[0].clone()).unwrap(); + audit_overlap.insert(overlapping_audit).unwrap(); + assert!(audit_overlap.finish().is_err()); + + let mut tampered_audit_manifest = audit_manifest_mirror(&audit_manifest); + tampered_audit_manifest.chunk_list_commitment = + Digest::new(HashAlgorithm::Blake3_256, [0x76; 32]); + let tampered_audit_manifest = audit_manifest_from_mirror(&tampered_audit_manifest).unwrap(); + let mut audit_root = ProviderAuditExportAssembler::new(tampered_audit_manifest).unwrap(); + for chunk in audit_chunks.iter().cloned() { + audit_root.insert(chunk).unwrap(); + } + assert!(audit_root.finish().is_err()); + + let (_, foreign_audit_chunks) = foreign_audit.interchange_parts().unwrap(); + let mut audit_cross = ProviderAuditExportAssembler::new(audit_manifest.clone()).unwrap(); + assert!(audit_cross.insert(foreign_audit_chunks[0].clone()).is_err()); + + let mut reversed_audit_chunks = audit_chunks.clone(); + reversed_audit_chunks.reverse(); + let replay = reversed_audit_chunks[0].clone(); + let mut audit_assembler = ProviderAuditExportAssembler::new(audit_manifest).unwrap(); + assert!(audit_assembler.insert(replay.clone()).unwrap()); + assert!(!audit_assembler.insert(replay).unwrap()); + for chunk in reversed_audit_chunks.into_iter().skip(1) { + audit_assembler.insert(chunk).unwrap(); + } + let rebuilt_audit = audit_assembler.finish().unwrap(); + assert_eq!(rebuilt_audit, audit); + + let (_, _, recovery) = populated_fixture(2, 0x77, 0x78); + let (recovery_manifest, generation_chunks, audit_chunks) = + recovery.interchange_parts().unwrap(); + let mut generation_assembler = + ProviderGenerationExportAssembler::new(recovery_manifest.generation().clone()).unwrap(); + for chunk in generation_chunks.into_iter().rev() { + generation_assembler.insert(chunk).unwrap(); + } + let mut audit_assembler = + ProviderAuditExportAssembler::new(recovery_manifest.audit().clone()).unwrap(); + for chunk in audit_chunks.into_iter().rev() { + audit_assembler.insert(chunk).unwrap(); + } + assert_eq!( + recovery_manifest + .finish( + generation_assembler.finish().unwrap(), + audit_assembler.finish().unwrap(), + ) + .unwrap(), + recovery + ); +} + +#[test] +fn provider_recovery_manifest_rejects_incomplete_component_finish() { + let (_, _, recovery) = populated_fixture(2, 0x81, 0x82); + let (manifest, generation_chunks, audit_chunks) = recovery.interchange_parts().unwrap(); + assert_canonical::(&manifest); + + let mut generation = + ProviderGenerationExportAssembler::new(manifest.generation().clone()).unwrap(); + for chunk in generation_chunks { + generation.insert(chunk).unwrap(); + } + let generation = generation.finish().unwrap(); + let incomplete_audit = ProviderAuditExportAssembler::new(manifest.audit().clone()).unwrap(); + assert!(incomplete_audit.finish().is_err()); + assert_ne!(generation.latest_head(), None); + assert!(!audit_chunks.is_empty()); +} diff --git a/protocols/krikos-identity/tests/publication.rs b/protocols/krikos-identity/tests/publication.rs new file mode 100644 index 00000000000..55ae5b4939f --- /dev/null +++ b/protocols/krikos-identity/tests/publication.rs @@ -0,0 +1,873 @@ +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, +}; + +use futures_lite::future::block_on; +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountId, AccountOperation, AccountState, AdmissionEvidence, + AlgorithmSignature, CanonicalWire, CheckpointAuthorization, CheckpointId, ControlPolicy, + ControllerApprovalBody, ControllerApprovals, ControllerClass, ControllerDescriptor, + ControllerKeyId, ControllerScope, ControllerSelector, ControllerThreshold, ControllerWeight, + CryptoSuiteDescriptor, DelayEvidence, Digest, DurationMillis, Epoch, EventBody, + EventPredecessors, Extensions, FreshnessEvidence, FreshnessRequirement, HashAlgorithm, + IdentityError, InclusionReceipt, KeyedSignature, OperationKind, PolicyRule, ProtocolSignature, + ProviderCheckpointBundle, ProviderCheckpointLineagePage, ProviderDescriptor, ProviderHeadBody, + ProviderId, ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, + ProviderPolicy, ProviderPolicyVersion, ProviderQuorum, PublicationStage, PublicationTracker, + PublishedCheckpoint, RecoveryAuthority, RecoveryPolicy, RecoveryPolicyVersion, RequiredWeight, + Sequence, SignedCheckpoint, SignedControllerApproval, SignedProviderHead, SigningPublicKey, + StoreFuture, Timestamp, TransparencyClient, build_checkpoint_body, + build_provider_checkpoint_bundle_from_genesis, limits::MAX_HISTORY_PAGE_EVENTS, + merkle::AppendOnlyMerkleLog, publish_checkpoint_concurrently, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn provider(secret: &SecretKey) -> ProviderDescriptor { + ProviderDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn verified_checkpoint(policy: &ProviderPolicy) -> (AccountId, ProviderCheckpointBundle) { + let signer = SecretKey::from_bytes(&[0x71; 32]); + let control_policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + PolicyRule::new( + OperationKind::ChangeProviderPolicy, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x72; 32], + Timestamp::from_unix_millis(1), + control_policy, + vec![controller(&signer)], + recovery_policy, + policy.clone(), + Extensions::default(), + ) + .unwrap(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let event_body = EventBody::new( + state.account_id(), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(state.genesis_anchor()), + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x73; 32]))), + Timestamp::from_unix_millis(2), + [0x74; 16], + Extensions::default(), + ) + .unwrap(); + let admission_checkpoint = typed_id::(0x75); + let evidence = AdmissionEvidence::new( + event_body.proposal_id().unwrap(), + admission_checkpoint, + state.provider_policy_id(), + FreshnessEvidence::local_known(admission_checkpoint), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let event_approval_body = ControllerApprovalBody::event( + state.active_controllers()[0].id(), + evidence.event_id_for_body(&event_body).unwrap(), + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let event_signature = signer.sign(&event_approval_body.to_canonical_bytes().unwrap()); + let event_approval = SignedControllerApproval::new( + event_approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, event_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + let event = krikos_identity::AuthorizedEvent::new( + event_body, + evidence, + ControllerApprovals::new(vec![event_approval]).unwrap(), + ) + .unwrap(); + state.validate_and_apply(&event).unwrap(); + + let checkpoint_body = build_checkpoint_body(&state, Timestamp::from_unix_millis(3)).unwrap(); + let checkpoint_id = checkpoint_body.checkpoint_id().unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signing_key) + .unwrap() + .id(); + let checkpoint_approval_body = + ControllerApprovalBody::checkpoint(controller_id, checkpoint_id, Extensions::default()) + .unwrap(); + let checkpoint_signature = signer.sign(&checkpoint_approval_body.to_canonical_bytes().unwrap()); + let checkpoint_approval = SignedControllerApproval::new( + checkpoint_approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, checkpoint_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + let checkpoint = SignedCheckpoint::new( + checkpoint_body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![checkpoint_approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + let bundle = build_provider_checkpoint_bundle_from_genesis( + &genesis, + std::slice::from_ref(&event), + &checkpoint, + None, + ) + .unwrap(); + (state.account_id(), bundle) +} + +fn receipt( + secret: &SecretKey, + provider: &ProviderDescriptor, + account_id: AccountId, + checkpoint_id: CheckpointId, + observed_at: u64, + head_at: u64, + fill: u8, +) -> InclusionReceipt { + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + typed_id::(fill), + account_id, + ProviderLogSubject::Checkpoint(checkpoint_id), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + let root = entry.merkle_leaf_hash().unwrap(); + let body = ProviderHeadBody::new( + provider.id().unwrap(), + entry.log_id(), + ProviderKeyVersion::GENESIS, + 1, + root, + Timestamp::from_unix_millis(head_at), + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&body.signing_bytes().unwrap()); + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(body, ProtocolSignature::ed25519(signature.to_bytes())), + ) + .unwrap() +} + +struct ConcurrentResultFuture { + started: Arc, + expected: usize, + registered: bool, + result: Option>, +} + +impl Future for ConcurrentResultFuture { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + if !self.registered { + self.started.fetch_add(1, Ordering::SeqCst); + self.registered = true; + } + if self.started.load(Ordering::SeqCst) >= self.expected { + Poll::Ready(self.result.take().unwrap()) + } else { + context.waker().wake_by_ref(); + Poll::Pending + } + } +} + +struct Client { + provider_id: ProviderId, + result: Result, + started: Arc, + expected: usize, +} + +impl TransparencyClient for Client { + fn provider_id(&self) -> ProviderId { + self.provider_id + } + + fn publish_checkpoint<'a>( + &'a self, + _checkpoint: &'a ProviderCheckpointBundle, + ) -> StoreFuture<'a, InclusionReceipt> { + Box::pin(ConcurrentResultFuture { + started: Arc::clone(&self.started), + expected: self.expected, + registered: false, + result: Some(self.result.clone()), + }) + } + + fn latest_checkpoint( + &self, + _account_id: AccountId, + ) -> StoreFuture<'_, Option> { + Box::pin(async { Ok(None) }) + } + + fn fetch_checkpoint_bundle( + &self, + _account_id: AccountId, + _checkpoint_id: CheckpointId, + ) -> StoreFuture<'_, Option> { + Box::pin(async { Err(IdentityError::ProviderUnavailable) }) + } + + fn fetch_checkpoint_lineage_page( + &self, + _account_id: AccountId, + _start_checkpoint_id: CheckpointId, + _maximum_records: usize, + _maximum_bytes: usize, + ) -> StoreFuture<'_, Option> { + Box::pin(async { Err(IdentityError::ProviderUnavailable) }) + } + + fn consistency_proof( + &self, + _log_id: ProviderLogId, + _old_size: u64, + _new_size: u64, + ) -> StoreFuture<'_, krikos_identity::merkle::MerkleConsistencyProof> { + Box::pin(async { Err(IdentityError::ProviderUnavailable) }) + } +} + +#[test] +fn publication_never_overclaims_threshold_or_submission_ack_observation() { + let first_secret = SecretKey::from_bytes(&[0x41; 32]); + let second_secret = SecretKey::from_bytes(&[0x42; 32]); + let first = provider(&first_secret); + let second = provider(&second_secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![first.clone(), second.clone()], + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(2).unwrap(), + krikos_identity::DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let (account_id, verified) = verified_checkpoint(&policy); + let checkpoint_id = verified.verified_checkpoint().checkpoint_id(); + let mut tracker = + PublicationTracker::new(account_id, checkpoint_id, policy.id().unwrap(), &policy).unwrap(); + assert_eq!(tracker.stage(), PublicationStage::Draft); + let mut unrelated = PublicationTracker::new( + account_id, + typed_id::(0x52), + policy.id().unwrap(), + &policy, + ) + .unwrap(); + assert!(matches!( + unrelated.mark_authorized(verified.verified_checkpoint()), + Err(IdentityError::InvalidRelationship { .. }) + )); + assert_eq!(unrelated.stage(), PublicationStage::Draft); + + let first_submission = receipt( + &first_secret, + &first, + account_id, + checkpoint_id, + 10, + 10, + 0x61, + ); + let served = + PublishedCheckpoint::new(verified.clone(), first_submission.clone(), &first).unwrap(); + assert_eq!( + served.bundle().verified_checkpoint().checkpoint_id(), + checkpoint_id + ); + assert_eq!(served.receipt(), &first_submission); + assert!(PublishedCheckpoint::new(verified.clone(), first_submission.clone(), &second).is_err()); + assert!(matches!( + tracker.record_publication(first_submission.clone()), + Err(IdentityError::InvalidRelationship { .. }) + )); + tracker + .mark_authorized(verified.verified_checkpoint()) + .unwrap(); + tracker + .record_publication(first_submission.clone()) + .unwrap(); + assert_eq!(tracker.stage(), PublicationStage::Published); + tracker + .record_publication(first_submission.clone()) + .unwrap(); + assert_eq!(tracker.published_provider_count(), 1); + assert_eq!( + tracker.publication_receipts(), + std::slice::from_ref(&first_submission) + ); + let different_log_retry = receipt( + &first_secret, + &first, + account_id, + checkpoint_id, + 12, + 12, + 0x69, + ); + tracker.record_publication(different_log_retry).unwrap(); + assert_eq!(tracker.published_provider_count(), 1); + assert_eq!( + tracker.publication_receipts(), + std::slice::from_ref(&first_submission) + ); + let same_log_equivocation = receipt( + &first_secret, + &first, + account_id, + checkpoint_id, + 12, + 12, + 0x61, + ); + assert_eq!( + tracker.record_publication(same_log_equivocation), + Err(IdentityError::ProviderEquivocation) + ); + assert_eq!(tracker.published_provider_count(), 1); + + let second_submission = receipt( + &second_secret, + &second, + account_id, + checkpoint_id, + 11, + 11, + 0x62, + ); + tracker + .record_publication(second_submission.clone()) + .unwrap(); + assert_eq!(tracker.stage(), PublicationStage::Replicated); + assert!(tracker.preferred_replication_reached()); + + let equal_size_proof = AppendOnlyMerkleLog::from_leaf_hashes(vec![ + first_submission.entry().merkle_leaf_hash().unwrap(), + ]) + .unwrap() + .consistency_proof(1) + .unwrap(); + assert!(matches!( + tracker.record_observation(first_submission, &equal_size_proof), + Err(IdentityError::InvalidRelationship { .. }) + )); + let first_observation = receipt( + &first_secret, + &first, + account_id, + checkpoint_id, + 10, + 20, + 0x61, + ); + tracker + .record_observation(first_observation, &equal_size_proof) + .unwrap(); + assert_eq!(tracker.stage(), PublicationStage::Replicated); + + let second_proof = AppendOnlyMerkleLog::from_leaf_hashes(vec![ + second_submission.entry().merkle_leaf_hash().unwrap(), + ]) + .unwrap() + .consistency_proof(1) + .unwrap(); + let second_observation = receipt( + &second_secret, + &second, + account_id, + checkpoint_id, + 11, + 21, + 0x62, + ); + tracker + .record_observation(second_observation, &second_proof) + .unwrap(); + assert_eq!(tracker.stage(), PublicationStage::Observed); +} + +#[test] +fn public_lineage_page_rejects_record_overflow_before_relationship_work() { + let secret = SecretKey::from_bytes(&[0x43; 32]); + let descriptor = provider(&secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![descriptor.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + krikos_identity::DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let (account_id, bundle) = verified_checkpoint(&policy); + let checkpoint_id = bundle.verified_checkpoint().checkpoint_id(); + let receipt = receipt( + &secret, + &descriptor, + account_id, + checkpoint_id, + 10, + 10, + 0x62, + ); + let log_id = receipt.entry().log_id(); + let published = PublishedCheckpoint::new(bundle, receipt, &descriptor).unwrap(); + let checkpoints = vec![published; MAX_HISTORY_PAGE_EVENTS + 1]; + + assert_eq!( + ProviderCheckpointLineagePage::new( + account_id, + checkpoint_id, + checkpoints, + None, + &descriptor, + log_id, + ), + Err(IdentityError::LimitExceeded { + resource: "provider checkpoint lineage records", + actual: MAX_HISTORY_PAGE_EVENTS + 1, + maximum: MAX_HISTORY_PAGE_EVENTS, + }) + ); +} + +#[test] +fn publication_rejects_unconfigured_or_wrong_subject_receipts_without_mutation() { + let configured_secret = SecretKey::from_bytes(&[0x43; 32]); + let outsider_secret = SecretKey::from_bytes(&[0x44; 32]); + let configured = provider(&configured_secret); + let outsider = provider(&outsider_secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![configured], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + krikos_identity::DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let (account_id, verified) = verified_checkpoint(&policy); + let checkpoint_id = verified.verified_checkpoint().checkpoint_id(); + let mut tracker = + PublicationTracker::new(account_id, checkpoint_id, policy.id().unwrap(), &policy).unwrap(); + tracker + .mark_authorized(verified.verified_checkpoint()) + .unwrap(); + let before = tracker.clone(); + let outsider_receipt = receipt( + &outsider_secret, + &outsider, + account_id, + checkpoint_id, + 10, + 10, + 0x63, + ); + assert_eq!( + tracker.record_publication(outsider_receipt), + Err(IdentityError::FreshnessUnavailable) + ); + assert_eq!(tracker, before); +} + +#[test] +fn concurrent_publication_preserves_partial_failures_and_retry_thresholds() { + let first_secret = SecretKey::from_bytes(&[0x81; 32]); + let second_secret = SecretKey::from_bytes(&[0x82; 32]); + let third_secret = SecretKey::from_bytes(&[0x83; 32]); + let first = provider(&first_secret); + let second = provider(&second_secret); + let third = provider(&third_secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![first.clone(), second.clone(), third.clone()], + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(3).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let (account_id, verified) = verified_checkpoint(&policy); + let checkpoint_id = verified.verified_checkpoint().checkpoint_id(); + let first_receipt = receipt( + &first_secret, + &first, + account_id, + checkpoint_id, + 10, + 10, + 0x84, + ); + let second_receipt = receipt( + &second_secret, + &second, + account_id, + checkpoint_id, + 11, + 11, + 0x85, + ); + let started = Arc::new(AtomicUsize::new(0)); + let first_client = Client { + provider_id: first.id().unwrap(), + result: Ok(first_receipt), + started: Arc::clone(&started), + expected: 3, + }; + let second_timeout = Client { + provider_id: second.id().unwrap(), + result: Err(IdentityError::ProviderTimeout), + started: Arc::clone(&started), + expected: 3, + }; + let third_rate_limited = Client { + provider_id: third.id().unwrap(), + result: Err(IdentityError::ProviderRateLimited), + started: Arc::clone(&started), + expected: 3, + }; + let mut tracker = + PublicationTracker::new(account_id, checkpoint_id, policy.id().unwrap(), &policy).unwrap(); + let batch = block_on(publish_checkpoint_concurrently( + &mut tracker, + &verified, + &[&first_client, &second_timeout, &third_rate_limited], + )) + .unwrap(); + assert_eq!(started.load(Ordering::SeqCst), 3); + assert_eq!(batch.stage(), PublicationStage::Published); + assert_eq!(tracker.published_provider_count(), 1); + assert!(batch.outcomes().iter().any(|outcome| { + outcome.provider_id() == second.id().unwrap() + && outcome.result() == &Err(IdentityError::ProviderTimeout) + })); + assert!(batch.outcomes().iter().any(|outcome| { + outcome.provider_id() == third.id().unwrap() + && outcome.result() == &Err(IdentityError::ProviderRateLimited) + })); + + let retry_started = Arc::new(AtomicUsize::new(0)); + let second_retry = Client { + provider_id: second.id().unwrap(), + result: Ok(second_receipt), + started: Arc::clone(&retry_started), + expected: 1, + }; + let retry = block_on(publish_checkpoint_concurrently( + &mut tracker, + &verified, + &[&second_retry], + )) + .unwrap(); + assert_eq!(retry_started.load(Ordering::SeqCst), 1); + assert_eq!(retry.stage(), PublicationStage::Replicated); + assert_eq!(tracker.published_provider_count(), 2); + assert!(!tracker.preferred_replication_reached()); + assert!(retry.outcomes().iter().any(|outcome| { + outcome.provider_id() == third.id().unwrap() + && outcome.result() == &Err(IdentityError::ProviderUnavailable) + })); +} + +#[cfg(feature = "provider-store")] +#[test] +fn durable_operational_journal_recomputes_thresholds_and_retains_same_phase_receipts() { + use krikos_identity::{ + AccountStore as _, ClaimEffects, LeaseId, MemoryAccountStore, OperationalEffectJournal, + OperationalEffectPhase, ProjectionEffect, RedbOperationalEffectStore, + }; + + let first_secret = SecretKey::from_bytes(&[0x91; 32]); + let second_secret = SecretKey::from_bytes(&[0x92; 32]); + let third_secret = SecretKey::from_bytes(&[0x93; 32]); + let first = provider(&first_secret); + let second = provider(&second_secret); + let third = provider(&third_secret); + let policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![first.clone(), second.clone(), third.clone()], + ProviderQuorum::new(3).unwrap(), + ProviderQuorum::new(3).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let (account_id, bundle) = verified_checkpoint(&policy); + let checkpoint = bundle.verified_checkpoint(); + let checkpoint_id = checkpoint.checkpoint_id(); + + let account_store = MemoryAccountStore::new(); + let initial = + block_on(account_store.create_account(bundle.genesis().unwrap().clone())).unwrap(); + block_on(account_store.commit_event(initial.revision().clone(), bundle.events()[0].clone())) + .unwrap(); + let lease_id = LeaseId::new([0x94; 16]).unwrap(); + let claimed = block_on( + account_store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(100), + Timestamp::from_unix_millis(200), + lease_id, + 4, + ) + .unwrap(), + ), + ) + .unwrap(); + let effect = claimed + .iter() + .find(|effect| { + matches!( + effect.effect(), + ProjectionEffect::PublishAccountEvent { .. } + ) + }) + .unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("operational-publication.redb"); + let store = RedbOperationalEffectStore::open(&path).unwrap(); + let journal = OperationalEffectJournal::new(store.clone()); + journal + .begin(effect, Timestamp::from_unix_millis(101)) + .unwrap(); + journal + .record_checkpoint_draft( + effect.id(), + checkpoint.checkpoint().body().clone(), + Timestamp::from_unix_millis(102), + ) + .unwrap(); + journal + .record_checkpoint_authorized( + effect.id(), + checkpoint, + &policy, + Timestamp::from_unix_millis(103), + ) + .unwrap(); + assert!(matches!( + journal.record_completed(effect.id(), Timestamp::from_unix_millis(103)), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let first_publication = receipt( + &first_secret, + &first, + account_id, + checkpoint_id, + 10, + 10, + 0xa1, + ); + let second_publication = receipt( + &second_secret, + &second, + account_id, + checkpoint_id, + 11, + 11, + 0xa2, + ); + let third_publication = receipt( + &third_secret, + &third, + account_id, + checkpoint_id, + 12, + 12, + 0xa3, + ); + let mut tracker = + PublicationTracker::new(account_id, checkpoint_id, policy.id().unwrap(), &policy).unwrap(); + tracker.mark_authorized(checkpoint).unwrap(); + tracker + .record_publication(first_publication.clone()) + .unwrap(); + let first_record = journal + .record_publications(effect.id(), &tracker, Timestamp::from_unix_millis(104)) + .unwrap(); + assert_eq!(first_record.phase(), OperationalEffectPhase::Published); + assert_eq!(first_record.provider_receipts().len(), 1); + assert_eq!(first_record.publication_policy(), Some(&policy)); + + tracker + .record_publication(second_publication.clone()) + .unwrap(); + let same_phase = journal + .record_publications(effect.id(), &tracker, Timestamp::from_unix_millis(105)) + .unwrap(); + assert_eq!(same_phase.phase(), OperationalEffectPhase::Published); + assert_eq!(same_phase.provider_receipts().len(), 2); + assert_eq!(same_phase.revision(), 5); + drop(journal); + drop(store); + + let reopened = RedbOperationalEffectStore::open(&path).unwrap(); + let journal = OperationalEffectJournal::new(reopened); + let retained = journal.load(effect.id()).unwrap().unwrap(); + assert_eq!(retained.phase(), OperationalEffectPhase::Published); + assert_eq!(retained.provider_receipts().len(), 2); + assert_eq!(retained.publication_policy(), Some(&policy)); + + tracker + .record_publication(third_publication.clone()) + .unwrap(); + let replicated = journal + .record_publications(effect.id(), &tracker, Timestamp::from_unix_millis(106)) + .unwrap(); + assert_eq!(replicated.phase(), OperationalEffectPhase::Replicated); + + for (index, (secret, descriptor, publication, fill)) in [ + (&first_secret, &first, &first_publication, 0xa1), + (&second_secret, &second, &second_publication, 0xa2), + (&third_secret, &third, &third_publication, 0xa3), + ] + .into_iter() + .enumerate() + { + let proof = AppendOnlyMerkleLog::from_leaf_hashes(vec![ + publication.entry().merkle_leaf_hash().unwrap(), + ]) + .unwrap() + .consistency_proof(1) + .unwrap(); + let observation = receipt( + secret, + descriptor, + account_id, + checkpoint_id, + 10 + u64::try_from(index).unwrap(), + 20 + u64::try_from(index).unwrap(), + fill, + ); + tracker + .record_observation(observation.clone(), &proof) + .unwrap(); + let retained = journal + .record_observation( + effect.id(), + &tracker, + observation, + proof, + Timestamp::from_unix_millis(107 + u64::try_from(index).unwrap()), + ) + .unwrap(); + let expected = if index == 2 { + OperationalEffectPhase::Observed + } else { + OperationalEffectPhase::Replicated + }; + assert_eq!(retained.phase(), expected); + assert_eq!( + retained + .provider_receipts() + .iter() + .filter(|provider| provider.observation().is_some()) + .count(), + index + 1 + ); + } + + drop(journal); + let reopened = RedbOperationalEffectStore::open(&path).unwrap(); + let journal = OperationalEffectJournal::new(reopened); + let observed = journal.load(effect.id()).unwrap().unwrap(); + assert_eq!(observed.phase(), OperationalEffectPhase::Observed); + assert_eq!(observed.provider_receipts().len(), 3); + assert!( + observed + .provider_receipts() + .iter() + .all(|provider| provider.observation().is_some()) + ); +} diff --git a/protocols/krikos-identity/tests/recovery_guardians.rs b/protocols/krikos-identity/tests/recovery_guardians.rs new file mode 100644 index 00000000000..45ae2949541 --- /dev/null +++ b/protocols/krikos-identity/tests/recovery_guardians.rs @@ -0,0 +1,381 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, BlindingSecret, CanonicalWire, ControllerWeight, Digest, DurationMillis, Epoch, + Extensions, GuardianApprovalBody, GuardianApprovalDecision, GuardianApprovalSet, + GuardianAuthorityContext, GuardianGrant, GuardianGrantOpening, GuardianSetRoot, + GuardianThreshold, HashAlgorithm, IdentityError, ProtocolSignature, ProtocolVersion, + RecoveryAuthority, RecoveryId, RecoveryPolicy, RecoveryPolicyId, RecoveryPolicyVersion, + RequiredWeight, SignedGuardianApproval, SigningPublicKey, Timestamp, merkle::MerkleSet, + verify_guardian_authority, +}; + +const PROTECTED_ACCOUNT_FILL: u8 = 0x11; +const RECOVERY_FILL: u8 = 0x22; +const POLICY_VERSION: RecoveryPolicyVersion = RecoveryPolicyVersion::new(7); +const ACCOUNT_EPOCH: Epoch = Epoch::new(9); +const APPROVED_AT: Timestamp = Timestamp::from_unix_millis(50_000); +const AUTHORITY_TIME: Timestamp = Timestamp::from_unix_millis(50_100); + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +struct GuardianSpec { + secret: SecretKey, + account_id: AccountId, + weight: ControllerWeight, + blinding: [u8; 32], + expires_at: Option, +} + +impl GuardianSpec { + fn new(seed: u8, weight: u32, expires_at: Option) -> Self { + Self { + secret: SecretKey::from_bytes(&[seed; 32]), + account_id: typed_id::(seed.checked_add(0x40).unwrap()), + weight: ControllerWeight::new(weight).unwrap(), + blinding: [seed.checked_add(0x60).unwrap(); 32], + expires_at, + } + } + + fn grant(&self, recovery_policy_id: RecoveryPolicyId) -> GuardianGrant { + GuardianGrant::try_new( + ProtocolVersion::V1, + typed_id::(PROTECTED_ACCOUNT_FILL), + recovery_policy_id, + self.account_id, + SigningPublicKey::ed25519(*self.secret.public().as_bytes()).unwrap(), + self.weight, + Epoch::new(3), + self.expires_at, + Extensions::default(), + ) + .unwrap() + } + + fn blinding(&self) -> BlindingSecret { + BlindingSecret::try_new(self.blinding).unwrap() + } +} + +struct GuardianUniverse { + specs: Vec, + policy: RecoveryPolicy, + set: MerkleSet, + root: GuardianSetRoot, +} + +impl GuardianUniverse { + fn new(required_weight: u32) -> Self { + let specs = vec![ + GuardianSpec::new(1, 1, Some(Timestamp::from_unix_millis(80_000))), + GuardianSpec::new(2, 1, Some(Timestamp::from_unix_millis(80_000))), + GuardianSpec::new(3, 1, Some(Timestamp::from_unix_millis(80_000))), + ]; + let placeholder_policy = typed_id::(0xee); + let leaves = specs + .iter() + .map(|spec| { + spec.grant(placeholder_policy) + .blinded_merkle_leaf(&spec.blinding()) + .unwrap() + }) + .collect(); + let set = MerkleSet::new(leaves).unwrap(); + let root = GuardianSetRoot::new(set.root().unwrap()).unwrap(); + let policy = RecoveryPolicy::new( + POLICY_VERSION, + RecoveryAuthority::guardian_threshold( + GuardianThreshold::new( + root, + u16::try_from(specs.len()).unwrap(), + u64::try_from(specs.len()).unwrap(), + RequiredWeight::new(required_weight).unwrap(), + ) + .unwrap(), + ), + DurationMillis::new(1_000), + DurationMillis::new(30_000), + Extensions::default(), + ) + .unwrap(); + Self { + specs, + policy, + set, + root, + } + } + + fn approval( + &self, + guardian_index: usize, + proof_index: usize, + context: GuardianAuthorityContext, + approved_at: Timestamp, + ) -> SignedGuardianApproval { + let spec = &self.specs[guardian_index]; + let opening = self.opening(guardian_index, proof_index); + let body = GuardianApprovalBody::try_new( + ProtocolVersion::V1, + context.protected_account_id(), + context.recovery_id(), + context.decision(), + opening.guardian_grant_id(), + context.account_epoch(), + approved_at, + Extensions::default(), + ) + .unwrap(); + let signature = spec.secret.sign(&body.signing_bytes().unwrap()); + SignedGuardianApproval::try_new( + body, + opening, + ProtocolSignature::ed25519(signature.to_bytes()), + ) + .unwrap() + } + + fn opening(&self, guardian_index: usize, proof_index: usize) -> GuardianGrantOpening { + let spec = &self.specs[guardian_index]; + let policy_id = self.policy.id().unwrap(); + let grant = spec.grant(policy_id); + let proof_leaf = self.specs[proof_index] + .grant(policy_id) + .blinded_merkle_leaf(&self.specs[proof_index].blinding()) + .unwrap(); + let proof = self.set.inclusion_proof(proof_leaf.key()).unwrap(); + GuardianGrantOpening::try_new( + ProtocolVersion::V1, + grant, + spec.blinding(), + self.root, + u16::try_from(proof.leaf_index()).unwrap(), + proof.audit_path().to_vec(), + Extensions::default(), + ) + .unwrap() + } + + fn context(&self, decision: GuardianApprovalDecision) -> GuardianAuthorityContext { + GuardianAuthorityContext::try_new( + typed_id::(PROTECTED_ACCOUNT_FILL), + typed_id::(RECOVERY_FILL), + self.policy.id().unwrap(), + POLICY_VERSION, + ACCOUNT_EPOCH, + decision, + AUTHORITY_TIME, + ) + .unwrap() + } + + fn approvals(&self, indexes: &[usize]) -> GuardianApprovalSet { + let context = self.context(GuardianApprovalDecision::Begin); + GuardianApprovalSet::try_new( + indexes + .iter() + .map(|index| self.approval(*index, *index, context, APPROVED_AT)) + .collect(), + ) + .unwrap() + } +} + +#[test] +fn exact_current_guardian_membership_and_threshold_are_required() { + let universe = GuardianUniverse::new(2); + let context = universe.context(GuardianApprovalDecision::Begin); + let exact = universe.approvals(&[0, 1]); + assert_eq!( + format!("{:?}", exact.as_slice()[0].opening().grant()), + "GuardianGrant()" + ); + assert_eq!( + format!("{:?}", exact.as_slice()[0].opening()), + "GuardianGrantOpening()" + ); + let verified = verify_guardian_authority(&universe.policy, &exact, &context).unwrap(); + assert_eq!(verified.approval_count(), 2); + assert_eq!(verified.total_weight(), 2); + assert_eq!(verified.recovery_id(), context.recovery_id()); + + let minority = universe.approvals(&[0]); + assert!(matches!( + verify_guardian_authority(&universe.policy, &minority, &context), + Err(IdentityError::UnsatisfiableThreshold | IdentityError::AuthorizationDenied) + )); +} + +#[test] +fn wrong_leaf_opening_and_forged_signature_do_not_count() { + let universe = GuardianUniverse::new(1); + let context = universe.context(GuardianApprovalDecision::Begin); + let wrong_path = + GuardianApprovalSet::try_new(vec![universe.approval(0, 1, context, APPROVED_AT)]).unwrap(); + assert_eq!( + verify_guardian_authority(&universe.policy, &wrong_path, &context), + Err(IdentityError::InvalidProof) + ); + + let valid = universe.approvals(&[0]); + let approval = &valid.as_slice()[0]; + let forged = approval.with_signature(ProtocolSignature::ed25519([0x99; 64])); + let forged = GuardianApprovalSet::try_new(vec![forged]).unwrap(); + assert_eq!( + verify_guardian_authority(&universe.policy, &forged, &context), + Err(IdentityError::InvalidSignature) + ); +} + +#[test] +fn every_signed_recovery_subject_field_is_exact() { + let universe = GuardianUniverse::new(1); + let begin = universe.context(GuardianApprovalDecision::Begin); + let opening = universe.opening(0, 0); + let wrong_account_body = GuardianApprovalBody::try_new( + ProtocolVersion::V1, + typed_id::(0x77), + begin.recovery_id(), + begin.decision(), + opening.guardian_grant_id(), + begin.account_epoch(), + APPROVED_AT, + Extensions::default(), + ) + .unwrap(); + let wrong_account_signature = universe.specs[0] + .secret + .sign(&wrong_account_body.signing_bytes().unwrap()); + assert!(matches!( + SignedGuardianApproval::try_new( + wrong_account_body, + opening, + ProtocolSignature::ed25519(wrong_account_signature.to_bytes()), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let cases = [ + universe.approval( + 0, + 0, + universe.context(GuardianApprovalDecision::Cancel), + APPROVED_AT, + ), + universe.approval( + 0, + 0, + GuardianAuthorityContext::try_new( + begin.protected_account_id(), + typed_id::(0x78), + begin.recovery_policy_id(), + begin.recovery_policy_version(), + begin.account_epoch(), + begin.decision(), + begin.authority_time(), + ) + .unwrap(), + APPROVED_AT, + ), + universe.approval( + 0, + 0, + GuardianAuthorityContext::try_new( + begin.protected_account_id(), + begin.recovery_id(), + begin.recovery_policy_id(), + begin.recovery_policy_version(), + Epoch::new(begin.account_epoch().get() + 1), + begin.decision(), + begin.authority_time(), + ) + .unwrap(), + APPROVED_AT, + ), + universe.approval( + 0, + 0, + begin, + Timestamp::from_unix_millis(AUTHORITY_TIME.as_unix_millis() + 1), + ), + ]; + + for approval in cases { + let approvals = GuardianApprovalSet::try_new(vec![approval]).unwrap(); + assert!(verify_guardian_authority(&universe.policy, &approvals, &begin).is_err()); + } +} + +#[test] +fn expired_revoked_and_duplicate_guardians_fail_closed() { + // Rebuild through the normal constructor so the root commits the expiring grant. + let expired = { + let mut universe = GuardianUniverse::new(1); + universe.specs[0].expires_at = Some(AUTHORITY_TIME); + let placeholder = typed_id::(0xee); + universe.set = MerkleSet::new( + universe + .specs + .iter() + .map(|spec| { + spec.grant(placeholder) + .blinded_merkle_leaf(&spec.blinding()) + .unwrap() + }) + .collect(), + ) + .unwrap(); + universe.root = GuardianSetRoot::new(universe.set.root().unwrap()).unwrap(); + universe.policy = RecoveryPolicy::new( + POLICY_VERSION, + RecoveryAuthority::guardian_threshold( + GuardianThreshold::new(universe.root, 3, 3, RequiredWeight::new(1).unwrap()) + .unwrap(), + ), + DurationMillis::new(1_000), + DurationMillis::new(30_000), + Extensions::default(), + ) + .unwrap(); + universe + }; + let context = expired.context(GuardianApprovalDecision::Begin); + assert!(matches!( + verify_guardian_authority(&expired.policy, &expired.approvals(&[0]), &context), + Err(IdentityError::StaleEvidence | IdentityError::InvalidRelationship { .. }) + )); + + let universe = GuardianUniverse::new(1); + let approval = universe.approvals(&[0]).as_slice()[0].clone(); + assert!(matches!( + GuardianApprovalSet::try_new(vec![approval.clone(), approval]), + Err(IdentityError::DuplicateElement { .. }) + )); + + let old = universe.approvals(&[0]); + let rotated_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::new(POLICY_VERSION.get() + 1), + RecoveryAuthority::guardian_threshold( + GuardianThreshold::new(universe.root, 3, 3, RequiredWeight::new(1).unwrap()).unwrap(), + ), + DurationMillis::new(1_000), + DurationMillis::new(30_000), + Extensions::default(), + ) + .unwrap(); + let rotated_context = GuardianAuthorityContext::try_new( + typed_id::(PROTECTED_ACCOUNT_FILL), + typed_id::(RECOVERY_FILL), + rotated_policy.id().unwrap(), + rotated_policy.policy_version(), + ACCOUNT_EPOCH, + GuardianApprovalDecision::Begin, + AUTHORITY_TIME, + ) + .unwrap(); + // A current policy never accepts evidence tied to an unrelated grant set/policy instance. + assert!(verify_guardian_authority(&rotated_policy, &old, &rotated_context).is_err()); +} diff --git a/protocols/krikos-identity/tests/recovery_schema.rs b/protocols/krikos-identity/tests/recovery_schema.rs new file mode 100644 index 00000000000..70edea5b2d2 --- /dev/null +++ b/protocols/krikos-identity/tests/recovery_schema.rs @@ -0,0 +1,734 @@ +use krikos_identity::{ + AccountId, AccountOperation, AdmissionEvidence, AlgorithmSignature, AuthorizedEvent, + BeginRecovery, BlindingSecret, CancelRecovery, CanonicalWire, CheckpointId, ControlPolicy, + ControllerApprovalBody, ControllerApprovals, ControllerClass, ControllerDescriptor, + ControllerId, ControllerKeyId, ControllerScope, ControllerSelector, ControllerThreshold, + ControllerWeight, CryptoSuiteId, DelayEvidence, DeviceId, Digest, DurationMillis, Epoch, + EventBody, EventId, EventPredecessors, Extension, Extensions, FinalizeRecovery, + ForkCommonAncestor, ForkDescriptor, ForkId, FreshnessEvidence, GenesisAnchor, + GuardianApprovalBody, GuardianApprovalDecision, GuardianApprovalSet, GuardianGrant, + GuardianGrantId, GuardianGrantOpening, GuardianSetRoot, HashAlgorithm, IdentityError, + InclusionReceipt, KeyedSignature, OperationKind, PolicyRule, ProtocolSignature, + ProtocolVersion, ProviderHeadBody, ProviderId, ProviderKeyVersion, ProviderLogEntryBody, + ProviderLogId, ProviderLogSubject, ProviderPolicyId, ProviderQuorum, RecoveryAuthority, + RecoveryAuthorityPlan, RecoveryDelayAnchor, RecoveryId, RecoveryPolicy, RecoveryPolicyId, + RecoveryPolicyVersion, RecoveryProposal, RecoveryThresholdEvidence, RequiredWeight, + ResolveFork, Sequence, SignedControllerApproval, SignedGuardianApproval, SignedProviderHead, + SigningPublicKey, Timestamp, VetoRecovery, + limits::{MAX_FORK_HEADS, MAX_RECOVERY_GUARDIANS}, +}; + +const SIGNING_KEY_1: [u8; 32] = [ + 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a, + 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a, +]; +const SIGNING_KEY_2: [u8; 32] = [ + 0x3d, 0x40, 0x17, 0xc3, 0xe8, 0x43, 0x89, 0x5a, 0x92, 0xb7, 0x0a, 0xa7, 0x4d, 0x1b, 0x7e, 0xbc, + 0x9c, 0x98, 0x2c, 0xcf, 0x2e, 0xc4, 0x96, 0x8c, 0xc0, 0xcd, 0x55, 0xf1, 0x2a, 0xf4, 0x66, 0x0c, +]; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(key: [u8; 32]) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(key).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn control_policy() -> ControlPolicy { + let rules = [ + OperationKind::BeginRecovery, + OperationKind::VetoRecovery, + OperationKind::CancelRecovery, + OperationKind::FinalizeRecovery, + OperationKind::ResolveFork, + ] + .into_iter() + .map(|operation| { + PolicyRule::new( + operation, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + krikos_identity::FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap() + }) + .collect(); + ControlPolicy::new(rules, Extensions::default()).unwrap() +} + +fn recovery_policy(version: u64) -> RecoveryPolicy { + RecoveryPolicy::new( + RecoveryPolicyVersion::new(version), + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(1_000), + DurationMillis::new(10_000), + Extensions::default(), + ) + .unwrap() +} + +fn plan() -> RecoveryAuthorityPlan { + RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + typed_id::(1), + typed_id::(2), + typed_id::(3), + typed_id::(4), + RecoveryPolicyVersion::new(4), + [5; 32], + vec![controller(SIGNING_KEY_2), controller(SIGNING_KEY_1)], + control_policy(), + recovery_policy(5), + vec![typed_id::(8), typed_id::(7)], + Timestamp::from_unix_millis(20_000), + Extensions::default(), + ) + .unwrap() +} + +fn proposal() -> RecoveryProposal { + RecoveryProposal::try_new(ProtocolVersion::V1, plan(), Extensions::default()).unwrap() +} + +fn begin_recovery() -> BeginRecovery { + let proposal = proposal(); + let evidence = RecoveryThresholdEvidence::controller_policy( + proposal.plan().recovery_policy_id(), + proposal.plan().recovery_policy_version(), + ); + BeginRecovery::try_new( + ProtocolVersion::V1, + proposal, + evidence, + Extensions::default(), + ) + .unwrap() +} + +fn final_approval(event_id: EventId, evidence: &AdmissionEvidence) -> ControllerApprovals { + let body = ControllerApprovalBody::event( + typed_id::(41), + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + ControllerApprovals::new(vec![ + SignedControllerApproval::new( + body, + vec![KeyedSignature::new( + typed_id::(42), + typed_id::(43), + AlgorithmSignature::new(1, vec![44; 64]).unwrap(), + )], + ) + .unwrap(), + ]) + .unwrap() +} + +#[test] +fn begin_recovery_event_binds_prior_head_and_checkpoint() { + let begin = begin_recovery(); + let plan = begin.proposal().plan(); + + assert!(matches!( + EventBody::new( + plan.account_id(), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id(31)), + AccountOperation::BeginRecovery(begin.clone()), + Timestamp::from_unix_millis(10), + [32; 16], + Extensions::default(), + ), + Err(IdentityError::InvalidPredecessor) + )); + + assert!(matches!( + EventBody::new( + plan.account_id(), + Sequence::new(2), + Epoch::new(1), + EventPredecessors::events(vec![typed_id(33)]).unwrap(), + AccountOperation::BeginRecovery(begin.clone()), + Timestamp::from_unix_millis(10), + [34; 16], + Extensions::default(), + ), + Err(IdentityError::InvalidPredecessor) + )); + + let body = EventBody::new( + plan.account_id(), + Sequence::new(2), + Epoch::new(1), + EventPredecessors::events(vec![plan.prior_event_head()]).unwrap(), + AccountOperation::BeginRecovery(begin), + Timestamp::from_unix_millis(10), + [35; 16], + Extensions::default(), + ) + .unwrap(); + let wrong_checkpoint = typed_id::(36); + let admission = AdmissionEvidence::new( + body.proposal_id().unwrap(), + wrong_checkpoint, + typed_id::(37), + FreshnessEvidence::local_known(wrong_checkpoint), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let approvals = final_approval(admission.event_id_for_body(&body).unwrap(), &admission); + assert!(matches!( + AuthorizedEvent::new(body.clone(), admission.clone(), approvals.clone()), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let wire = postcard::to_stdvec(&(body, admission, approvals)).unwrap(); + assert!(AuthorizedEvent::from_canonical_bytes(&wire).is_err()); +} + +fn provider_receipt( + provider_fill: u8, + account_id: AccountId, + proposal_id: krikos_identity::ProposalId, + observed_at: u64, +) -> InclusionReceipt { + let provider_id = typed_id::(provider_fill); + let log_id = typed_id::(provider_fill.wrapping_add(32)); + let entry = ProviderLogEntryBody::new( + provider_id, + log_id, + account_id, + ProviderLogSubject::EventIntent(proposal_id), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + let head = ProviderHeadBody::new( + provider_id, + log_id, + ProviderKeyVersion::GENESIS, + 1, + Digest::new(HashAlgorithm::Blake3_256, [provider_fill; 32]), + Timestamp::from_unix_millis(observed_at + 1), + Extensions::default(), + ) + .unwrap(); + InclusionReceipt::new( + entry, + 0, + vec![], + SignedProviderHead::new(head, ProtocolSignature::ed25519([provider_fill; 64])), + ) + .unwrap() +} + +#[test] +fn recovery_plan_is_sorted_bounded_and_body_id_is_stable() { + let proposal = proposal(); + let authority_plan = proposal.plan(); + assert!( + authority_plan.replacement_controllers()[0].id().unwrap() + < authority_plan.replacement_controllers()[1].id().unwrap() + ); + assert_eq!( + authority_plan.retained_devices(), + &[typed_id::(7), typed_id::(8)] + ); + assert_eq!( + RecoveryProposal::from_canonical_bytes(&proposal.to_canonical_bytes().unwrap()).unwrap(), + proposal + ); + assert_eq!( + proposal.recovery_id().unwrap().to_string(), + "b3:57a8b5623760855c922b2aeeb98b72339f967939f802f6dabf4124e0417e80c5" + ); + + let base = plan(); + assert!(matches!( + RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + base.account_id(), + base.prior_checkpoint_id(), + base.prior_event_head(), + base.recovery_policy_id(), + base.recovery_policy_version(), + [0; 32], + base.replacement_controllers().to_vec(), + base.replacement_control_policy().clone(), + base.replacement_recovery_policy().clone(), + base.retained_devices().to_vec(), + base.expires_at(), + Extensions::default(), + ), + Err(IdentityError::ZeroValue { .. }) + )); + assert!( + RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + base.account_id(), + base.prior_checkpoint_id(), + base.prior_event_head(), + base.recovery_policy_id(), + base.recovery_policy_version(), + [1; 32], + base.replacement_controllers().to_vec(), + base.replacement_control_policy().clone(), + base.replacement_recovery_policy().clone(), + vec![typed_id::(9), typed_id::(9)], + base.expires_at(), + Extensions::default(), + ) + .is_err() + ); + + let duplicate_controller = base.replacement_controllers()[0].clone(); + assert!(matches!( + RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + base.account_id(), + base.prior_checkpoint_id(), + base.prior_event_head(), + base.recovery_policy_id(), + base.recovery_policy_version(), + [1; 32], + vec![duplicate_controller.clone(), duplicate_controller], + base.replacement_control_policy().clone(), + base.replacement_recovery_policy().clone(), + base.retained_devices().to_vec(), + base.expires_at(), + Extensions::default(), + ), + Err(IdentityError::DuplicateElement { .. }) + )); + + let mut reversed_controllers = base.replacement_controllers().to_vec(); + reversed_controllers.reverse(); + let unsorted_wire = postcard::to_stdvec(&( + ProtocolVersion::V1, + base.account_id(), + base.prior_checkpoint_id(), + base.prior_event_head(), + base.recovery_policy_id(), + base.recovery_policy_version(), + [1; 32], + reversed_controllers, + base.replacement_control_policy().clone(), + base.replacement_recovery_policy().clone(), + base.retained_devices().to_vec(), + base.expires_at(), + Extensions::default(), + )) + .unwrap(); + assert!(RecoveryAuthorityPlan::from_canonical_bytes(&unsorted_wire).is_err()); +} + +#[test] +fn guardian_openings_and_approvals_are_bound_and_mergeable() { + let protected_account = typed_id::(1); + let recovery_policy_id = typed_id::(4); + let root = GuardianSetRoot::new(Digest::new(HashAlgorithm::Blake3_256, [9; 32])).unwrap(); + let recovery_id = proposal().recovery_id().unwrap(); + + let signed = |guardian_fill: u8, key: [u8; 32]| { + let grant = GuardianGrant::try_new( + ProtocolVersion::V1, + protected_account, + recovery_policy_id, + typed_id::(guardian_fill), + SigningPublicKey::ed25519(key).unwrap(), + ControllerWeight::new(1).unwrap(), + Epoch::GENESIS, + Some(Timestamp::from_unix_millis(30_000)), + Extensions::default(), + ) + .unwrap(); + let opening = GuardianGrantOpening::try_new( + ProtocolVersion::V1, + grant, + BlindingSecret::try_new([guardian_fill; 32]).unwrap(), + root, + u16::from(guardian_fill - 1), + vec![], + Extensions::default(), + ) + .unwrap(); + let body = GuardianApprovalBody::try_new( + ProtocolVersion::V1, + protected_account, + recovery_id, + GuardianApprovalDecision::Begin, + opening.guardian_grant_id(), + Epoch::GENESIS, + Timestamp::from_unix_millis(10_000), + Extensions::default(), + ) + .unwrap(); + SignedGuardianApproval::try_new( + body, + opening, + ProtocolSignature::ed25519([guardian_fill; 64]), + ) + .unwrap() + }; + + let first = signed(1, SIGNING_KEY_1); + let second = signed(2, SIGNING_KEY_2); + let one = GuardianApprovalSet::try_new(vec![first.clone()]).unwrap(); + let two = GuardianApprovalSet::try_new(vec![second, first.clone()]).unwrap(); + assert_eq!(one.merge(&two).unwrap(), two); + assert_eq!(two.recovery_id(), recovery_id); + assert_eq!(two.guardian_set_root(), root); + assert!(GuardianApprovalSet::try_new(vec![first.clone(), first.clone()]).is_err()); + assert_eq!( + GuardianApprovalSet::from_canonical_bytes(&two.to_canonical_bytes().unwrap()).unwrap(), + two + ); + + let mut reversed = two.as_slice().to_vec(); + reversed.reverse(); + let reversed_wire = postcard::to_stdvec(&reversed).unwrap(); + assert!(GuardianApprovalSet::from_canonical_bytes(&reversed_wire).is_err()); + + let oversized = vec![first.clone(); MAX_RECOVERY_GUARDIANS + 1]; + let oversized_wire = postcard::to_stdvec(&oversized).unwrap(); + assert!(GuardianApprovalSet::from_canonical_bytes(&oversized_wire).is_err()); + + assert!(matches!( + BlindingSecret::try_new([0; 32]), + Err(IdentityError::ZeroValue { .. }) + )); + + let mut tampered_approval = first.to_canonical_bytes().unwrap(); + let original_id = first + .body() + .guardian_grant_id() + .to_canonical_bytes() + .unwrap(); + let replacement_id = typed_id::(99) + .to_canonical_bytes() + .unwrap(); + let occurrences = tampered_approval + .windows(original_id.len()) + .enumerate() + .filter_map(|(index, window)| (window == original_id).then_some(index)) + .collect::>(); + assert_eq!(occurrences.len(), 2); + let opening_id_offset = occurrences[1]; + tampered_approval[opening_id_offset..opening_id_offset + original_id.len()] + .copy_from_slice(&replacement_id); + assert!(SignedGuardianApproval::from_canonical_bytes(&tampered_approval).is_err()); +} + +#[test] +fn recovery_operations_bind_vacancy_policy_and_exact_pending_id() { + let proposal = proposal(); + let recovery_id = proposal.recovery_id().unwrap(); + let evidence = RecoveryThresholdEvidence::controller_policy( + proposal.plan().recovery_policy_id(), + proposal.plan().recovery_policy_version(), + ); + let begin = BeginRecovery::try_new( + ProtocolVersion::V1, + proposal.clone(), + evidence.clone(), + Extensions::default(), + ) + .unwrap(); + assert!(begin.requires_vacant_recovery_slot()); + assert_eq!(begin.recovery_id(), recovery_id); + + let occupied_wire = postcard::to_stdvec(&( + ProtocolVersion::V1, + Some(recovery_id), + recovery_id, + proposal.clone(), + evidence.clone(), + Extensions::default(), + )) + .unwrap(); + assert!(BeginRecovery::from_canonical_bytes(&occupied_wire).is_err()); + let wrong_id_wire = postcard::to_stdvec(&( + ProtocolVersion::V1, + Option::::None, + typed_id::(90), + proposal.clone(), + evidence.clone(), + Extensions::default(), + )) + .unwrap(); + assert!(BeginRecovery::from_canonical_bytes(&wrong_id_wire).is_err()); + + let checkpoint = proposal.plan().prior_checkpoint_id(); + let freshness = FreshnessEvidence::local_known(checkpoint); + let veto = VetoRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + typed_id(22), + freshness.clone(), + Extensions::default(), + ) + .unwrap(); + assert_eq!(veto.expected_pending_recovery(), recovery_id); + + let cancel = CancelRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + evidence, + freshness, + Extensions::default(), + ) + .unwrap(); + assert_eq!(cancel.expected_pending_recovery(), recovery_id); + assert_eq!( + CancelRecovery::from_canonical_bytes(&cancel.to_canonical_bytes().unwrap()).unwrap(), + cancel + ); +} + +#[test] +fn finalize_delay_anchor_is_the_quorum_th_earliest_distinct_observation() { + let account_id = typed_id::(1); + let recovery_id = proposal().recovery_id().unwrap(); + let begin_proposal_id = typed_id(44); + let receipts = krikos_identity::ProviderReceipts::new(vec![ + provider_receipt(3, account_id, begin_proposal_id, 300), + provider_receipt(1, account_id, begin_proposal_id, 100), + provider_receipt(2, account_id, begin_proposal_id, 200), + ]) + .unwrap(); + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + account_id, + recovery_id, + begin_proposal_id, + typed_id::(45), + ProviderQuorum::new(2).unwrap(), + receipts, + Extensions::default(), + ) + .unwrap(); + assert_eq!(anchor.observed_at(), Timestamp::from_unix_millis(200)); + assert!(matches!( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor.clone(), + Timestamp::from_unix_millis(199), + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + let finalize = FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor, + Timestamp::from_unix_millis(1_200), + Extensions::default(), + ) + .unwrap(); + assert_eq!(finalize.expected_pending_recovery(), recovery_id); + + let insufficient = krikos_identity::ProviderReceipts::new(vec![provider_receipt( + 1, + account_id, + begin_proposal_id, + 100, + )]) + .unwrap(); + assert!(matches!( + RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + account_id, + recovery_id, + begin_proposal_id, + typed_id::(45), + ProviderQuorum::new(2).unwrap(), + insufficient, + Extensions::default(), + ), + Err(IdentityError::UnsatisfiableThreshold) + )); +} + +#[test] +fn fork_descriptor_and_choose_one_resolution_are_canonical() { + let account_id = typed_id::(1); + let ancestor = typed_id::(2); + let first = typed_id::(3); + let second = typed_id::(4); + let fork = ForkDescriptor::try_new( + ProtocolVersion::V1, + account_id, + ForkCommonAncestor::Event(ancestor), + vec![second, first], + Extensions::default(), + ) + .unwrap(); + assert_eq!(fork.heads(), &[first, second]); + assert_eq!(fork.common_ancestor(), ForkCommonAncestor::Event(ancestor)); + assert_eq!( + hex::encode(fork.to_canonical_bytes().unwrap()), + "01010101010101010101010101010101010101010101010101010101010101010101020102020202020202020202020202020202020202020202020202020202020202020201030303030303030303030303030303030303030303030303030303030303030301040404040404040404040404040404040404040404040404040404040404040400" + ); + assert_eq!( + fork.fork_id().unwrap().to_string(), + "b3:a15afec9c970e685ed3f0cc380c77dcfea47798e70cf6e443ac00784ad93b36a" + ); + + let genesis_fork = ForkDescriptor::try_new( + ProtocolVersion::V1, + account_id, + ForkCommonAncestor::Genesis(typed_id::(2)), + vec![first, second], + Extensions::default(), + ) + .unwrap(); + assert_eq!( + ForkDescriptor::from_canonical_bytes(&genesis_fork.to_canonical_bytes().unwrap()).unwrap(), + genesis_fork + ); + assert_ne!(genesis_fork.fork_id().unwrap(), fork.fork_id().unwrap()); + + let resolution = ResolveFork::try_new( + ProtocolVersion::V1, + fork.clone(), + second, + vec![typed_id::(7), typed_id::(6)], + vec![typed_id::(9), typed_id::(8)], + Extensions::default(), + ) + .unwrap(); + assert_eq!(resolution.selected_head(), second); + assert_eq!( + resolution.revoked_controllers(), + &[typed_id(6), typed_id(7)] + ); + assert_eq!(resolution.revoked_devices(), &[typed_id(8), typed_id(9)]); + assert!( + ResolveFork::try_new( + ProtocolVersion::V1, + fork.clone(), + typed_id::(99), + vec![], + vec![], + Extensions::default(), + ) + .is_err() + ); + assert!( + ForkDescriptor::try_new( + ProtocolVersion::V1, + account_id, + ForkCommonAncestor::Event(ancestor), + vec![first, first], + Extensions::default(), + ) + .is_err() + ); + assert_eq!( + ForkDescriptor::try_new( + ProtocolVersion::V1, + account_id, + ForkCommonAncestor::Event(first), + vec![first, second], + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { + resource: "fork ancestor/head", + }) + ); + assert!( + ResolveFork::try_new( + ProtocolVersion::V1, + fork.clone(), + second, + vec![typed_id::(6), typed_id::(6)], + vec![], + Extensions::default(), + ) + .is_err() + ); + + let tampered_id_wire = postcard::to_stdvec(&( + ProtocolVersion::V1, + typed_id::(90), + fork, + second, + Vec::::new(), + Vec::::new(), + Extensions::default(), + )) + .unwrap(); + assert!(ResolveFork::from_canonical_bytes(&tampered_id_wire).is_err()); +} + +#[test] +fn adversarial_wire_rejects_unsorted_oversized_and_unknown_critical_fields() { + let account_id = typed_id::(1); + let ancestor = typed_id::(2); + let first = typed_id::(3); + let second = typed_id::(4); + let unsorted = postcard::to_stdvec(&( + ProtocolVersion::V1, + account_id, + ForkCommonAncestor::Event(ancestor), + vec![second, first], + Extensions::default(), + )) + .unwrap(); + assert!(ForkDescriptor::from_canonical_bytes(&unsorted).is_err()); + + let oversized = postcard::to_stdvec(&( + ProtocolVersion::V1, + account_id, + ForkCommonAncestor::Event(ancestor), + vec![first; MAX_FORK_HEADS + 1], + Extensions::default(), + )) + .unwrap(); + assert!(ForkDescriptor::from_canonical_bytes(&oversized).is_err()); + + let unknown_ancestor = postcard::to_stdvec(&( + ProtocolVersion::V1, + account_id, + (3_u16, ancestor), + vec![first, second], + Extensions::default(), + )) + .unwrap(); + assert_eq!( + ForkDescriptor::from_canonical_bytes(&unknown_ancestor), + Err(IdentityError::InvalidEncoding) + ); + + let critical = Extensions::new(vec![Extension::new(999, true, vec![]).unwrap()]).unwrap(); + assert!( + ForkDescriptor::try_new( + ProtocolVersion::V1, + account_id, + ForkCommonAncestor::Event(ancestor), + vec![first, second], + critical, + ) + .is_err() + ); +} diff --git a/protocols/krikos-identity/tests/schema_limits.rs b/protocols/krikos-identity/tests/schema_limits.rs new file mode 100644 index 00000000000..7a594a43af8 --- /dev/null +++ b/protocols/krikos-identity/tests/schema_limits.rs @@ -0,0 +1,49 @@ +use krikos_identity::{ + AlgorithmPublicKey, AlgorithmSignature, CanonicalWire, ControllerWeight, GroupKeyEpoch, + IdentityError, ProviderQuorum, RequiredWeight, + limits::{MAX_ALGORITHM_PUBLIC_KEY_BYTES, MAX_ALGORITHM_SIGNATURE_BYTES}, +}; + +#[test] +fn schema_scalars_reject_zero_and_advance_checked() { + assert!(matches!( + ControllerWeight::new(0), + Err(IdentityError::ZeroValue { .. }) + )); + assert!(matches!( + RequiredWeight::new(0), + Err(IdentityError::ZeroValue { .. }) + )); + assert!(matches!( + ProviderQuorum::new(0), + Err(IdentityError::ZeroValue { .. }) + )); + assert_eq!(GroupKeyEpoch::GENESIS.checked_next().unwrap().get(), 1); + assert!(GroupKeyEpoch::new(u64::MAX).checked_next().is_err()); +} + +#[test] +fn migration_key_and_signature_material_is_bounded() { + let future_key = AlgorithmPublicKey::new(2, vec![7; MAX_ALGORITHM_PUBLIC_KEY_BYTES]).unwrap(); + assert_eq!( + AlgorithmPublicKey::from_canonical_bytes(&future_key.to_canonical_bytes().unwrap()) + .unwrap(), + future_key + ); + assert!(matches!( + AlgorithmPublicKey::new(2, vec![0; MAX_ALGORITHM_PUBLIC_KEY_BYTES + 1]), + Err(IdentityError::LimitExceeded { .. }) + )); + + let future_signature = + AlgorithmSignature::new(2, vec![9; MAX_ALGORITHM_SIGNATURE_BYTES]).unwrap(); + assert_eq!( + AlgorithmSignature::from_canonical_bytes(&future_signature.to_canonical_bytes().unwrap()) + .unwrap(), + future_signature + ); + assert!(matches!( + AlgorithmSignature::new(2, vec![0; MAX_ALGORITHM_SIGNATURE_BYTES + 1]), + Err(IdentityError::LimitExceeded { .. }) + )); +} diff --git a/protocols/krikos-identity/tests/social.rs b/protocols/krikos-identity/tests/social.rs new file mode 100644 index 00000000000..d417bd23999 --- /dev/null +++ b/protocols/krikos-identity/tests/social.rs @@ -0,0 +1,396 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, AlgorithmSignature, CanonicalWire, CheckpointId, Digest, Extensions, HashAlgorithm, + IdentityError, SignedSocialAttestation, SigningPublicKey, SocialAttestationBody, + SocialAttestationVerificationContext, SocialTransitivityPolicy, Timestamp, + evaluate_social_trust, verify_social_attestation, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn signed_attestation( + issuer_secret: &SecretKey, + issuer_account: AccountId, + issuer_checkpoint: CheckpointId, + subject_secret: &SecretKey, + subject_account: AccountId, + subject_checkpoint: CheckpointId, + claim_fill: u8, +) -> SignedSocialAttestation { + signed_attestation_for_interval( + issuer_secret, + issuer_account, + issuer_checkpoint, + subject_secret, + subject_account, + subject_checkpoint, + claim_fill, + Timestamp::from_unix_millis(10), + Some(Timestamp::from_unix_millis(20)), + ) +} + +#[allow(clippy::too_many_arguments)] +fn signed_attestation_for_interval( + issuer_secret: &SecretKey, + issuer_account: AccountId, + issuer_checkpoint: CheckpointId, + subject_secret: &SecretKey, + subject_account: AccountId, + subject_checkpoint: CheckpointId, + claim_fill: u8, + issued_at: Timestamp, + expires_at: Option, +) -> SignedSocialAttestation { + let body = SocialAttestationBody::try_new( + issuer_account, + issuer_checkpoint, + SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(), + subject_account, + subject_checkpoint, + SigningPublicKey::ed25519(*subject_secret.public().as_bytes()).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [claim_fill; 32]), + issued_at, + expires_at, + Extensions::default(), + ) + .unwrap(); + let signature = issuer_secret.sign(&body.signing_bytes().unwrap()); + SignedSocialAttestation::try_new( + body, + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + ) + .unwrap() +} + +#[test] +fn transitive_edges_with_disjoint_validity_windows_never_form_a_trust_path() { + let first_secret = SecretKey::from_bytes(&[0x01; 32]); + let second_secret = SecretKey::from_bytes(&[0x02; 32]); + let third_secret = SecretKey::from_bytes(&[0x03; 32]); + let first = signed_attestation_for_interval( + &first_secret, + typed_id::(0x04), + typed_id::(0x05), + &second_secret, + typed_id::(0x06), + typed_id::(0x07), + 0x08, + Timestamp::from_unix_millis(10), + Some(Timestamp::from_unix_millis(20)), + ); + let second = signed_attestation_for_interval( + &second_secret, + typed_id::(0x06), + typed_id::(0x07), + &third_secret, + typed_id::(0x09), + typed_id::(0x0a), + 0x08, + Timestamp::from_unix_millis(30), + Some(Timestamp::from_unix_millis(40)), + ); + let first = verify_social_attestation( + &first, + &context_from_body(first.body(), Timestamp::from_unix_millis(19)), + ) + .unwrap(); + let second = verify_social_attestation( + &second, + &context_from_body(second.body(), Timestamp::from_unix_millis(31)), + ) + .unwrap(); + + assert_eq!( + evaluate_social_trust( + &[first, second], + SocialTransitivityPolicy::bounded(2).unwrap(), + Timestamp::from_unix_millis(31), + ), + Err(IdentityError::StaleEvidence) + ); +} + +fn context_from_body( + body: &SocialAttestationBody, + authority_time: Timestamp, +) -> SocialAttestationVerificationContext { + SocialAttestationVerificationContext::try_new( + body.issuer_account_id(), + body.issuer_checkpoint_id(), + body.issuer_signing_key(), + body.subject_account_id(), + body.subject_checkpoint_id(), + body.subject_signing_key(), + body.claim_digest(), + authority_time, + ) + .unwrap() +} + +#[test] +fn social_attestation_binds_signature_subject_checkpoint_claim_and_expiry() { + let issuer_secret = SecretKey::from_bytes(&[0x11; 32]); + let subject_secret = SecretKey::from_bytes(&[0x12; 32]); + let issuer_account = typed_id::(0x13); + let issuer_checkpoint = typed_id::(0x14); + let subject_account = typed_id::(0x15); + let subject_checkpoint = typed_id::(0x16); + let attestation = signed_attestation( + &issuer_secret, + issuer_account, + issuer_checkpoint, + &subject_secret, + subject_account, + subject_checkpoint, + 0x17, + ); + let context = SocialAttestationVerificationContext::try_new( + issuer_account, + issuer_checkpoint, + SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(), + subject_account, + subject_checkpoint, + SigningPublicKey::ed25519(*subject_secret.public().as_bytes()).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0x17; 32]), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + let verified = verify_social_attestation(&attestation, &context).unwrap(); + assert_eq!(verified.subject_account_id(), subject_account); + assert_eq!(verified.authority_time(), Timestamp::from_unix_millis(19)); + + let wrong_claim = SocialAttestationVerificationContext::try_new( + issuer_account, + issuer_checkpoint, + SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(), + subject_account, + subject_checkpoint, + SigningPublicKey::ed25519(*subject_secret.public().as_bytes()).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0x18; 32]), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert!(verify_social_attestation(&attestation, &wrong_claim).is_err()); + + let wrong_subject_account = SocialAttestationVerificationContext::try_new( + issuer_account, + issuer_checkpoint, + SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(), + typed_id::(0x19), + subject_checkpoint, + SigningPublicKey::ed25519(*subject_secret.public().as_bytes()).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0x17; 32]), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert!(verify_social_attestation(&attestation, &wrong_subject_account).is_err()); + + let wrong_subject_checkpoint = SocialAttestationVerificationContext::try_new( + issuer_account, + issuer_checkpoint, + SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(), + subject_account, + typed_id::(0x1a), + SigningPublicKey::ed25519(*subject_secret.public().as_bytes()).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0x17; 32]), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert!(verify_social_attestation(&attestation, &wrong_subject_checkpoint).is_err()); + + let replacement_subject = SecretKey::from_bytes(&[0x1b; 32]); + let wrong_subject_key = SocialAttestationVerificationContext::try_new( + issuer_account, + issuer_checkpoint, + SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(), + subject_account, + subject_checkpoint, + SigningPublicKey::ed25519(*replacement_subject.public().as_bytes()).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0x17; 32]), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert!(verify_social_attestation(&attestation, &wrong_subject_key).is_err()); + + let expired = SocialAttestationVerificationContext::try_new( + issuer_account, + issuer_checkpoint, + SigningPublicKey::ed25519(*issuer_secret.public().as_bytes()).unwrap(), + subject_account, + subject_checkpoint, + SigningPublicKey::ed25519(*subject_secret.public().as_bytes()).unwrap(), + Digest::new(HashAlgorithm::Blake3_256, [0x17; 32]), + Timestamp::from_unix_millis(20), + ) + .unwrap(); + assert_eq!( + verify_social_attestation(&attestation, &expired), + Err(IdentityError::StaleEvidence) + ); + + let forged_body = attestation.body().clone(); + assert_eq!( + SignedSocialAttestation::try_new( + forged_body, + AlgorithmSignature::new(1, vec![0x55; 64]).unwrap(), + ), + Err(IdentityError::InvalidSignature) + ); +} + +#[test] +fn transitivity_is_default_off_explicitly_bounded_and_cycle_free() { + let first_secret = SecretKey::from_bytes(&[0x21; 32]); + let second_secret = SecretKey::from_bytes(&[0x22; 32]); + let third_secret = SecretKey::from_bytes(&[0x23; 32]); + let first_account = typed_id::(0x24); + let second_account = typed_id::(0x25); + let third_account = typed_id::(0x26); + let first_checkpoint = typed_id::(0x27); + let second_checkpoint = typed_id::(0x28); + let third_checkpoint = typed_id::(0x29); + let first = signed_attestation( + &first_secret, + first_account, + first_checkpoint, + &second_secret, + second_account, + second_checkpoint, + 0x2a, + ); + let second = signed_attestation( + &second_secret, + second_account, + second_checkpoint, + &third_secret, + third_account, + third_checkpoint, + 0x2a, + ); + let first = verify_social_attestation( + &first, + &context_from_body(first.body(), Timestamp::from_unix_millis(19)), + ) + .unwrap(); + let second = verify_social_attestation( + &second, + &context_from_body(second.body(), Timestamp::from_unix_millis(19)), + ) + .unwrap(); + assert!( + evaluate_social_trust( + &[first.clone(), second.clone()], + SocialTransitivityPolicy::default(), + Timestamp::from_unix_millis(19), + ) + .is_err() + ); + assert!( + evaluate_social_trust( + &[first.clone(), second.clone()], + SocialTransitivityPolicy::bounded(1).unwrap(), + Timestamp::from_unix_millis(19), + ) + .is_err() + ); + assert!(SocialTransitivityPolicy::bounded(0).is_err()); + assert!( + SocialTransitivityPolicy::bounded( + u8::try_from(krikos_identity::limits::MAX_SOCIAL_TRANSITIVITY_DEPTH + 1).unwrap() + ) + .is_err() + ); + let hint = evaluate_social_trust( + &[first.clone(), second], + SocialTransitivityPolicy::bounded(2).unwrap(), + Timestamp::from_unix_millis(19), + ) + .unwrap(); + assert_eq!(hint.depth(), 2); + assert_eq!(hint.subject_account_id(), third_account); + assert_eq!(hint.authority_time(), Timestamp::from_unix_millis(19)); + + let cycle = signed_attestation( + &second_secret, + second_account, + second_checkpoint, + &first_secret, + first_account, + first_checkpoint, + 0x2a, + ); + let cycle = verify_social_attestation( + &cycle, + &context_from_body(cycle.body(), Timestamp::from_unix_millis(19)), + ) + .unwrap(); + assert!( + evaluate_social_trust( + &[first, cycle], + SocialTransitivityPolicy::bounded(2).unwrap(), + Timestamp::from_unix_millis(19), + ) + .is_err() + ); +} + +#[test] +fn previously_verified_edge_cannot_be_replayed_after_expiry() { + let issuer_secret = SecretKey::from_bytes(&[0x2b; 32]); + let subject_secret = SecretKey::from_bytes(&[0x2c; 32]); + let attestation = signed_attestation( + &issuer_secret, + typed_id::(0x2d), + typed_id::(0x2e), + &subject_secret, + typed_id::(0x2f), + typed_id::(0x30), + 0x31, + ); + let verified = verify_social_attestation( + &attestation, + &context_from_body(attestation.body(), Timestamp::from_unix_millis(19)), + ) + .unwrap(); + + assert_eq!( + evaluate_social_trust( + &[verified], + SocialTransitivityPolicy::default(), + Timestamp::from_unix_millis(20), + ), + Err(IdentityError::StaleEvidence) + ); +} + +#[test] +fn social_body_and_signature_vector_is_canonical_and_bounded() { + let issuer = SecretKey::from_bytes(&[0x31; 32]); + let subject = SecretKey::from_bytes(&[0x32; 32]); + let attestation = signed_attestation( + &issuer, + typed_id::(0x33), + typed_id::(0x34), + &subject, + typed_id::(0x35), + typed_id::(0x36), + 0x37, + ); + let encoded = attestation.to_canonical_bytes().unwrap(); + assert_eq!( + SignedSocialAttestation::from_canonical_bytes(&encoded).unwrap(), + attestation + ); + assert_eq!( + blake3::hash(&encoded).as_bytes(), + &[ + 0x1d, 0x9c, 0xf8, 0xfa, 0x8e, 0x4d, 0x68, 0x42, 0xe3, 0xae, 0xc5, 0x30, 0x4e, 0xe0, + 0x26, 0x4c, 0xca, 0xe3, 0x6b, 0x85, 0x45, 0xef, 0x31, 0x9d, 0x15, 0xd8, 0x33, 0xc5, + 0xc7, 0x5a, 0x0b, 0xb2, + ] + ); +} diff --git a/protocols/krikos-identity/tests/state_machine.rs b/protocols/krikos-identity/tests/state_machine.rs new file mode 100644 index 00000000000..992197afe65 --- /dev/null +++ b/protocols/krikos-identity/tests/state_machine.rs @@ -0,0 +1,3914 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, ActivateCryptoMigration, AdmissionEvidence, + AgreementPublicKey, AlgorithmPublicKey, AlgorithmSignature, ApplyDisposition, + BeginCryptoMigration, BeginRecovery, BlindingSecret, CancelRecovery, CanonicalWire, + CheckpointAuthorization, CheckpointBody, CheckpointId, ControlPolicy, ControllerApprovalBody, + ControllerApprovals, ControllerClass, ControllerDescriptor, ControllerKeyBinding, + ControllerKeyBindingProof, ControllerKeyBindingProofSet, ControllerKeyId, ControllerScope, + ControllerSelector, ControllerThreshold, ControllerWeight, CryptoMigrationBody, + CryptoMigrationId, CryptoSuiteDescriptor, DelayEvidence, DeviceAuthorization, DeviceClass, + DeviceDescriptor, Digest, DurationMillis, EndpointPublicKey, Epoch, EventBody, EventId, + EventIntentApprovalBody, EventIntentApprovals, EventPredecessors, Extension, Extensions, + FinalizeRecovery, ForkCommonAncestor, ForkDescriptor, FreshnessEvidence, FreshnessRequirement, + GuardianApprovalBody, GuardianApprovalDecision, GuardianApprovalSet, GuardianGrant, + GuardianGrantOpening, GuardianSetRoot, GuardianThreshold, HashAlgorithm, IdentityError, + InclusionReceipt, KeyedSignature, MemoryTransparencyLog, OperationKind, PolicyRule, + ProjectionLifecycle, ProtocolSignature, ProtocolVersion, ProviderDescriptor, ProviderFreshness, + ProviderHeadBody, ProviderHeadSigner, ProviderKeyVersion, ProviderLogEntryBody, ProviderLogId, + ProviderLogSubject, ProviderPolicy, ProviderPolicyVersion, ProviderQuorum, ProviderReceipts, + RecoveryAuthority, RecoveryAuthorityPlan, RecoveryDelayAnchor, RecoveryId, RecoveryPolicy, + RecoveryPolicyId, RecoveryPolicyVersion, RecoveryProposal, RecoveryThresholdEvidence, + RequiredWeight, ResolveFork, RetireAccount, RetireCryptoSuite, RetireCryptoSuiteMode, + RevokeDevice, RotateDeviceKeys, Sequence, SignedCheckpoint, SignedControllerApproval, + SignedEventIntentApproval, SignedGuardianApproval, SignedProviderHead, SigningPublicKey, + Timestamp, VetoRecovery, build_checkpoint_body, merkle::MerkleSet, verify_checkpoint, + verify_event_intent_admission, verify_guardian_recovery_intent_admission, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +struct TestProviderSigner(SecretKey); + +impl ProviderHeadSigner for TestProviderSigner { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +fn controller(secret: &SecretKey, weight: u32) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(weight).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn large_controller(secret: &SecretKey) -> ControllerDescriptor { + let extensions = Extensions::new( + (100_u32..104) + .map(|code| Extension::new(code, false, vec![0xa5; 15 * 1024]).unwrap()) + .collect(), + ) + .unwrap(); + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::HardwareSecurityKey, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + extensions, + ) + .unwrap() +} + +fn device_descriptor( + application_secret: &SecretKey, + agreement_seed: u8, + endpoint_secret: &SecretKey, +) -> DeviceDescriptor { + let mut agreement = [0_u8; 32]; + agreement[0] = agreement_seed; + DeviceDescriptor::new( + SigningPublicKey::ed25519(*application_secret.public().as_bytes()).unwrap(), + AgreementPublicKey::x25519(agreement).unwrap(), + EndpointPublicKey::new( + SigningPublicKey::ed25519(*endpoint_secret.public().as_bytes()).unwrap(), + ), + Extensions::default(), + ) + .unwrap() +} + +fn device_authorization(descriptor: DeviceDescriptor, epoch: Epoch) -> DeviceAuthorization { + DeviceAuthorization::new( + descriptor.id().unwrap(), + descriptor, + DeviceClass::ApplicationOnly, + None, + Vec::new(), + epoch, + Extensions::default(), + ) + .unwrap() +} + +fn rule(operation: OperationKind, required_weight: u32) -> PolicyRule { + PolicyRule::new( + operation, + RequiredWeight::new(required_weight).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap() +} + +fn provider_rule(operation: OperationKind, required_weight: u32) -> PolicyRule { + PolicyRule::new( + operation, + RequiredWeight::new(required_weight).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::provider_quorum( + ProviderFreshness::new(ProviderQuorum::new(1).unwrap(), DurationMillis::new(1_000)) + .unwrap(), + ), + None, + Extensions::default(), + ) + .unwrap() +} + +fn fixture() -> (AccountGenesis, SecretKey) { + let secret = SecretKey::from_bytes(&[7; 32]); + let descriptor = controller(&secret, 1); + let policy = ControlPolicy::new( + vec![ + rule(OperationKind::AddController, 1), + rule(OperationKind::RemoveController, 1), + rule(OperationKind::AuthorizeDevice, 1), + rule(OperationKind::RevokeDevice, 1), + rule(OperationKind::RotateDeviceKeys, 1), + rule(OperationKind::ChangeProviderPolicy, 1), + rule(OperationKind::BeginRecovery, 1), + rule(OperationKind::VetoRecovery, 1), + rule(OperationKind::CancelRecovery, 1), + provider_rule(OperationKind::FinalizeRecovery, 1), + rule(OperationKind::ResolveFork, 1), + rule(OperationKind::BeginCryptoMigration, 1), + rule(OperationKind::ActivateCryptoMigration, 1), + rule(OperationKind::RetireCryptoSuite, 1), + rule(OperationKind::RetireAccount, 1), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let provider_secret = SecretKey::from_bytes(&[99; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [1; 32], + Timestamp::from_unix_millis(1), + policy, + vec![descriptor], + recovery, + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + (genesis, secret) +} + +fn authorized_event( + state: &AccountState, + operation: AccountOperation, + resulting_epoch: Epoch, + nonce: u8, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + resulting_epoch, + predecessors, + operation, + Timestamp::from_unix_millis(u64::from(nonce)), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + authorize_body(state, body, signer) +} + +fn authorize_body( + state: &AccountState, + body: EventBody, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let checkpoint_id = typed_id::(0x44); + let delay = if matches!(body.operation(), AccountOperation::BeginRecovery(_)) { + let proposal_id = body.proposal_id().unwrap(); + let observed_at = 100; + DelayEvidence::provider_quorum( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + controller_intent_approvals(state, &body, signer), + ProviderReceipts::new(vec![provider_receipt( + state, + ProviderLogSubject::EventIntent(proposal_id), + observed_at, + observed_at, + 0x67, + )]) + .unwrap(), + ) + .unwrap() + } else { + DelayEvidence::none() + }; + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + delay, + Extensions::default(), + ) + .unwrap(); + authorize_body_with_evidence(state, body, evidence, signer) +} + +fn controller_intent_approvals( + state: &AccountState, + body: &EventBody, + signer: &SecretKey, +) -> EventIntentApprovals { + let signer_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signer_key) + .unwrap() + .id(); + let intent_body = EventIntentApprovalBody::new( + controller_id, + body.proposal_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let intent_signature = signer.sign(&intent_body.to_canonical_bytes().unwrap()); + EventIntentApprovals::new(vec![ + SignedEventIntentApproval::new( + intent_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signer_key).unwrap(), + AlgorithmSignature::new(1, intent_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(), + ]) + .unwrap() +} + +fn authorize_body_with_evidence( + state: &AccountState, + body: EventBody, + evidence: AdmissionEvidence, + signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let event_id = evidence.event_id_for_body(&body).unwrap(); + let signer_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let signer_controller = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signer_key) + .unwrap(); + let approval_body = ControllerApprovalBody::event( + signer_controller.id(), + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let bytes = approval_body.to_canonical_bytes().unwrap(); + let signature = signer.sign(&bytes); + let keyed = KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signer_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + ); + let approval = SignedControllerApproval::new(approval_body, vec![keyed]).unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn authorize_body_with_crypto_keys( + state: &AccountState, + body: EventBody, + controller_id: krikos_identity::ControllerId, + signers: &[(&CryptoSuiteDescriptor, &SecretKey, bool)], +) -> krikos_identity::AuthorizedEvent { + let checkpoint_id = typed_id::(0x44); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + let approval_body = ControllerApprovalBody::event( + controller_id, + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let bytes = approval_body.to_canonical_bytes().unwrap(); + let keyed = signers + .iter() + .map(|(suite, signer, migrated_key_encoding)| { + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_key_id = if *migrated_key_encoding { + ControllerKeyId::for_algorithm_key( + &AlgorithmPublicKey::new( + suite.signature_algorithm_code(), + signing_key.as_bytes().to_vec(), + ) + .unwrap(), + ) + .unwrap() + } else { + ControllerKeyId::for_signing_key(&signing_key).unwrap() + }; + KeyedSignature::new( + suite.crypto_suite_id().unwrap(), + controller_key_id, + AlgorithmSignature::new( + suite.signature_algorithm_code(), + signer.sign(&bytes).to_bytes().to_vec(), + ) + .unwrap(), + ) + }) + .collect(); + let approval = SignedControllerApproval::new(approval_body, keyed).unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn authorized_event_with_crypto_keys( + state: &AccountState, + operation: AccountOperation, + resulting_epoch: Epoch, + nonce: u8, + controller_id: krikos_identity::ControllerId, + signers: &[(&CryptoSuiteDescriptor, &SecretKey, bool)], +) -> krikos_identity::AuthorizedEvent { + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + resulting_epoch, + predecessors, + operation, + Timestamp::from_unix_millis(u64::from(nonce)), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + authorize_body_with_crypto_keys(state, body, controller_id, signers) +} + +fn signed_checkpoint_with_crypto_keys( + body: CheckpointBody, + controller_id: krikos_identity::ControllerId, + signers: &[(&CryptoSuiteDescriptor, &SecretKey, bool)], +) -> SignedCheckpoint { + let checkpoint_id = body.checkpoint_id().unwrap(); + let approval_body = + ControllerApprovalBody::checkpoint(controller_id, checkpoint_id, Extensions::default()) + .unwrap(); + let bytes = approval_body.to_canonical_bytes().unwrap(); + let keyed = signers + .iter() + .map(|(suite, signer, migrated_key_encoding)| { + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_key_id = if *migrated_key_encoding { + ControllerKeyId::for_algorithm_key( + &AlgorithmPublicKey::new( + suite.signature_algorithm_code(), + signing_key.as_bytes().to_vec(), + ) + .unwrap(), + ) + .unwrap() + } else { + ControllerKeyId::for_signing_key(&signing_key).unwrap() + }; + KeyedSignature::new( + suite.crypto_suite_id().unwrap(), + controller_key_id, + AlgorithmSignature::new( + suite.signature_algorithm_code(), + signer.sign(&bytes).to_bytes().to_vec(), + ) + .unwrap(), + ) + }) + .collect(); + let approval = SignedControllerApproval::new(approval_body, keyed).unwrap(); + SignedCheckpoint::new( + body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap() +} + +fn migrate_to_in_place_ed25519_suite( + state: &mut AccountState, + original_secret: &SecretKey, + migrated_secret: &SecretKey, +) -> CryptoSuiteDescriptor { + let v1_suite = CryptoSuiteDescriptor::v1().unwrap(); + let migrated_suite = CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 2, + v1_suite.hash_algorithm_code(), + v1_suite.signature_algorithm_code(), + v1_suite.agreement_algorithm_code(), + v1_suite.kdf_algorithm_code(), + v1_suite.aead_algorithm_code(), + Extensions::default(), + ) + .unwrap(); + let controller_id = state.active_controllers()[0].id(); + let original_key = state.active_controllers()[0].signing_key(); + let migrated_key = AlgorithmPublicKey::new( + migrated_suite.signature_algorithm_code(), + migrated_secret.public().as_bytes().to_vec(), + ) + .unwrap(); + let migration = CryptoMigrationBody::try_new( + ProtocolVersion::V1, + state.account_id(), + v1_suite.crypto_suite_id().unwrap(), + migrated_suite.clone(), + vec![ + ControllerKeyBinding::try_new( + controller_id, + ControllerKeyId::for_signing_key(&original_key).unwrap(), + migrated_key, + Extensions::default(), + ) + .unwrap(), + ], + None, + [0xd1; 32], + Extensions::default(), + ) + .unwrap(); + let migration_id = migration.crypto_migration_id().unwrap(); + let migration_id_bytes = migration_id.to_canonical_bytes().unwrap(); + let proof = ControllerKeyBindingProof::try_new( + migration_id, + controller_id, + AlgorithmSignature::new( + 1, + original_secret + .sign(&migration_id_bytes) + .to_bytes() + .to_vec(), + ) + .unwrap(), + AlgorithmSignature::new( + 1, + migrated_secret + .sign(&migration_id_bytes) + .to_bytes() + .to_vec(), + ) + .unwrap(), + ) + .unwrap(); + let begin = AccountOperation::BeginCryptoMigration( + BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + ControllerKeyBindingProofSet::try_new(vec![proof]).unwrap(), + Extensions::default(), + ) + .unwrap(), + ); + let begin = authorized_event_with_crypto_keys( + state, + begin, + state.epoch(), + 0xd1, + controller_id, + &[(&v1_suite, original_secret, false)], + ); + let begin_event_id = begin.event_id().unwrap(); + state.validate_and_apply(&begin).unwrap(); + + let activate = AccountOperation::ActivateCryptoMigration( + ActivateCryptoMigration::try_new( + ProtocolVersion::V1, + migration_id, + begin_event_id, + Extensions::default(), + ) + .unwrap(), + ); + let activate = authorized_event_with_crypto_keys( + state, + activate, + state.epoch().checked_next().unwrap(), + 0xd2, + controller_id, + &[(&v1_suite, original_secret, false)], + ); + let activation_event_id = activate.event_id().unwrap(); + state.validate_and_apply(&activate).unwrap(); + + let retire = AccountOperation::RetireCryptoSuite( + RetireCryptoSuite::try_new( + ProtocolVersion::V1, + migration_id, + RetireCryptoSuiteMode::RetirePrevious, + activation_event_id, + None, + Extensions::default(), + ) + .unwrap(), + ); + let retire = authorized_event_with_crypto_keys( + state, + retire, + state.epoch().checked_next().unwrap(), + 0xd3, + controller_id, + &[ + (&v1_suite, original_secret, false), + (&migrated_suite, migrated_secret, true), + ], + ); + state.validate_and_apply(&retire).unwrap(); + migrated_suite +} + +fn in_place_suite(suite_code: u16) -> CryptoSuiteDescriptor { + let v1 = CryptoSuiteDescriptor::v1().unwrap(); + CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + suite_code, + v1.hash_algorithm_code(), + v1.signature_algorithm_code(), + v1.agreement_algorithm_code(), + v1.kdf_algorithm_code(), + v1.aead_algorithm_code(), + Extensions::default(), + ) + .unwrap() +} + +fn begin_migration_event( + state: &AccountState, + from_suite: &CryptoSuiteDescriptor, + old_secret: &SecretKey, + to_suite: CryptoSuiteDescriptor, + new_secret: &SecretKey, + nonce: u8, +) -> (krikos_identity::AuthorizedEvent, CryptoMigrationId) { + let controller_id = state.active_controllers()[0].id(); + let old_signing_key = SigningPublicKey::ed25519(*old_secret.public().as_bytes()).unwrap(); + let old_key_id = if from_suite == &CryptoSuiteDescriptor::v1().unwrap() { + ControllerKeyId::for_signing_key(&old_signing_key).unwrap() + } else { + ControllerKeyId::for_algorithm_key( + &AlgorithmPublicKey::new( + from_suite.signature_algorithm_code(), + old_signing_key.as_bytes().to_vec(), + ) + .unwrap(), + ) + .unwrap() + }; + let migration = CryptoMigrationBody::try_new( + ProtocolVersion::V1, + state.account_id(), + from_suite.crypto_suite_id().unwrap(), + to_suite.clone(), + vec![ + ControllerKeyBinding::try_new( + controller_id, + old_key_id, + AlgorithmPublicKey::new( + to_suite.signature_algorithm_code(), + new_secret.public().as_bytes().to_vec(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(), + ], + None, + [nonce; 32], + Extensions::default(), + ) + .unwrap(); + let migration_id = migration.crypto_migration_id().unwrap(); + let migration_bytes = migration_id.to_canonical_bytes().unwrap(); + let proof = ControllerKeyBindingProof::try_new( + migration_id, + controller_id, + AlgorithmSignature::new(1, old_secret.sign(&migration_bytes).to_bytes().to_vec()).unwrap(), + AlgorithmSignature::new(1, new_secret.sign(&migration_bytes).to_bytes().to_vec()).unwrap(), + ) + .unwrap(); + let begin = AccountOperation::BeginCryptoMigration( + BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + ControllerKeyBindingProofSet::try_new(vec![proof]).unwrap(), + Extensions::default(), + ) + .unwrap(), + ); + ( + authorized_event_with_crypto_keys( + state, + begin, + state.epoch(), + nonce, + controller_id, + &[(from_suite, old_secret, from_suite.suite_code() != 1)], + ), + migration_id, + ) +} + +fn complete_in_place_migration( + state: &mut AccountState, + from_suite: &CryptoSuiteDescriptor, + old_secret: &SecretKey, + to_suite: &CryptoSuiteDescriptor, + new_secret: &SecretKey, + nonce: u8, +) { + let controller_id = state.active_controllers()[0].id(); + let (begin, migration_id) = begin_migration_event( + state, + from_suite, + old_secret, + to_suite.clone(), + new_secret, + nonce, + ); + let begin_event_id = begin.event_id().unwrap(); + state.validate_and_apply(&begin).unwrap(); + let activate = authorized_event_with_crypto_keys( + state, + AccountOperation::ActivateCryptoMigration( + ActivateCryptoMigration::try_new( + ProtocolVersion::V1, + migration_id, + begin_event_id, + Extensions::default(), + ) + .unwrap(), + ), + state.epoch().checked_next().unwrap(), + nonce.saturating_add(1), + controller_id, + &[(from_suite, old_secret, from_suite.suite_code() != 1)], + ); + let activation_event_id = activate.event_id().unwrap(); + state.validate_and_apply(&activate).unwrap(); + let retire = authorized_event_with_crypto_keys( + state, + AccountOperation::RetireCryptoSuite( + RetireCryptoSuite::try_new( + ProtocolVersion::V1, + migration_id, + RetireCryptoSuiteMode::RetirePrevious, + activation_event_id, + None, + Extensions::default(), + ) + .unwrap(), + ), + state.epoch().checked_next().unwrap(), + nonce.saturating_add(2), + controller_id, + &[ + (from_suite, old_secret, from_suite.suite_code() != 1), + (to_suite, new_secret, true), + ], + ); + state.validate_and_apply(&retire).unwrap(); +} + +fn authorize_body_without_controller_approvals( + state: &AccountState, + body: EventBody, +) -> krikos_identity::AuthorizedEvent { + let checkpoint_id = typed_id::(0x44); + authorize_body_without_controller_approvals_with_freshness( + state, + body, + FreshnessEvidence::local_known(checkpoint_id), + ) +} + +fn authorize_body_without_controller_approvals_with_freshness( + state: &AccountState, + body: EventBody, + freshness: FreshnessEvidence, +) -> krikos_identity::AuthorizedEvent { + let checkpoint_id = freshness.checkpoint_id(); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + freshness, + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(Vec::new()).unwrap(), + ) + .unwrap() +} + +fn recovery_provider_receipt( + state: &AccountState, + proposal_id: krikos_identity::ProposalId, + observed_at: u64, +) -> InclusionReceipt { + provider_receipt( + state, + ProviderLogSubject::EventIntent(proposal_id), + observed_at, + observed_at + 10, + 0x67, + ) +} + +fn finalize_recovery_event( + state: &AccountState, + recovery_id: RecoveryId, + begin_proposal_id: krikos_identity::ProposalId, + nonce: u8, +) -> krikos_identity::AuthorizedEvent { + let provider_secret = SecretKey::from_bytes(&[99; 32]); + finalize_recovery_event_with_anchor_signer( + state, + recovery_id, + begin_proposal_id, + nonce, + &provider_secret, + ) +} + +fn finalize_recovery_event_with_anchor_signer( + state: &AccountState, + recovery_id: RecoveryId, + begin_proposal_id: krikos_identity::ProposalId, + nonce: u8, + anchor_signer: &SecretKey, +) -> krikos_identity::AuthorizedEvent { + let configured_provider = match state.provider_policy().mode() { + krikos_identity::ProviderMode::LocalOnly => panic!("fixture uses replicated providers"), + krikos_identity::ProviderMode::Replicated(policy) => &policy.providers()[0], + }; + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + begin_proposal_id, + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![provider_receipt_for_descriptor_signed_by( + state, + ProviderLogSubject::EventIntent(begin_proposal_id), + 100, + 110, + 0x67, + configured_provider, + anchor_signer, + )]) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let finalize = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor, + Timestamp::from_unix_millis(110), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.epoch().checked_next().unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + finalize, + Timestamp::from_unix_millis(110), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x44); + let completion = ProviderReceipts::new(vec![provider_receipt( + state, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + 110, + nonce, + )]) + .unwrap(); + authorize_body_without_controller_approvals_with_freshness( + state, + body, + FreshnessEvidence::provider_quorum(checkpoint_id, state.provider_policy_id(), completion) + .unwrap(), + ) +} + +fn provider_receipt( + state: &AccountState, + subject: ProviderLogSubject, + entry_observed_at: u64, + head_observed_at: u64, + log_fill: u8, +) -> InclusionReceipt { + let provider_secret = SecretKey::from_bytes(&[99; 32]); + provider_receipt_signed_by( + state, + subject, + entry_observed_at, + head_observed_at, + log_fill, + &provider_secret, + ) +} + +fn provider_receipt_signed_by( + state: &AccountState, + subject: ProviderLogSubject, + entry_observed_at: u64, + head_observed_at: u64, + log_fill: u8, + provider_secret: &SecretKey, +) -> InclusionReceipt { + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + provider_receipt_for_descriptor_signed_by( + state, + subject, + entry_observed_at, + head_observed_at, + log_fill, + &provider, + provider_secret, + ) +} + +#[allow(clippy::too_many_arguments)] +fn provider_receipt_for_descriptor_signed_by( + state: &AccountState, + subject: ProviderLogSubject, + entry_observed_at: u64, + head_observed_at: u64, + log_fill: u8, + provider: &ProviderDescriptor, + signing_secret: &SecretKey, +) -> InclusionReceipt { + let log_id = typed_id::(log_fill); + let entry = ProviderLogEntryBody::new( + provider.id().unwrap(), + log_id, + state.account_id(), + subject, + Timestamp::from_unix_millis(entry_observed_at), + Extensions::default(), + ) + .unwrap(); + let leaf_root = entry.merkle_leaf_hash().unwrap(); + let head = ProviderHeadBody::new( + provider.id().unwrap(), + log_id, + ProviderKeyVersion::GENESIS, + 1, + leaf_root, + Timestamp::from_unix_millis(head_observed_at), + Extensions::default(), + ) + .unwrap(); + let signature = signing_secret.sign(&head.signing_bytes().unwrap()); + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(head, ProtocolSignature::ed25519(signature.to_bytes())), + ) + .unwrap() +} + +fn begin_recovery_operation( + state: &AccountState, + signer: &SecretKey, +) -> (AccountOperation, RecoveryId) { + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0x51; 32], + vec![controller(signer, 1)], + state.control_policy().clone(), + state.recovery_policy().clone(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap(); + let recovery_id = proposal.recovery_id().unwrap(); + let evidence = RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ); + ( + AccountOperation::BeginRecovery( + BeginRecovery::try_new( + ProtocolVersion::V1, + proposal, + evidence, + Extensions::default(), + ) + .unwrap(), + ), + recovery_id, + ) +} + +#[test] +fn genesis_projection_and_linear_transition_are_deterministic() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + assert_eq!(state.sequence(), Sequence::GENESIS); + assert_eq!(state.epoch(), Epoch::GENESIS); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); + assert!(state.heads().is_empty()); + + let added_secret = SecretKey::from_bytes(&[8; 32]); + let event = authorized_event( + &state, + AccountOperation::AddController(controller(&added_secret, 1)), + Epoch::new(1), + 2, + &signer, + ); + let event_id = event.event_id().unwrap(); + let outcome = state.validate_and_apply(&event).unwrap(); + + assert_eq!(outcome.disposition(), ApplyDisposition::Applied); + assert_eq!(outcome.event_id(), event_id); + assert_eq!(state.sequence(), Sequence::new(1)); + assert_eq!(state.epoch(), Epoch::new(1)); + assert_eq!(state.heads(), [event_id]); + assert_eq!(state.active_controllers().len(), 2); +} + +#[test] +fn invalid_event_does_not_mutate_projection() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let added_secret = SecretKey::from_bytes(&[8; 32]); + let event = authorized_event( + &state, + AccountOperation::AddController(controller(&added_secret, 1)), + Epoch::GENESIS, + 3, + &signer, + ); + let before = state.clone(); + + assert_eq!( + state.validate_and_apply(&event), + Err(IdentityError::InvalidEpoch) + ); + assert_eq!(state, before); +} + +#[test] +fn device_and_controller_public_keys_are_permanently_role_separated() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + + let controller_as_application = + device_descriptor(&signer, 0xa0, &SecretKey::from_bytes(&[0xa1; 32])); + let cross_tier = authorized_event( + &state, + AccountOperation::AuthorizeDevice(device_authorization( + controller_as_application, + Epoch::new(1), + )), + Epoch::new(1), + 0xa1, + &signer, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&cross_tier), + Err(IdentityError::InvalidRelationship { + resource: "controller/device public-key role separation" + }) + ); + assert_eq!(state, before); + + let application_secret = SecretKey::from_bytes(&[0xa2; 32]); + let endpoint_secret = SecretKey::from_bytes(&[0xa3; 32]); + let original_descriptor = device_descriptor(&application_secret, 0xa4, &endpoint_secret); + let original_id = original_descriptor.id().unwrap(); + let authorize = authorized_event( + &state, + AccountOperation::AuthorizeDevice(device_authorization( + original_descriptor.clone(), + Epoch::new(1), + )), + Epoch::new(1), + 0xa2, + &signer, + ); + state.validate_and_apply(&authorize).unwrap(); + + let reused_descriptors = [ + device_descriptor( + &application_secret, + 0xa5, + &SecretKey::from_bytes(&[0xa6; 32]), + ), + device_descriptor( + &SecretKey::from_bytes(&[0xa7; 32]), + 0xa4, + &SecretKey::from_bytes(&[0xa8; 32]), + ), + device_descriptor(&SecretKey::from_bytes(&[0xa9; 32]), 0xaa, &endpoint_secret), + ]; + for (offset, descriptor) in reused_descriptors.into_iter().enumerate() { + let event = authorized_event( + &state, + AccountOperation::AuthorizeDevice(device_authorization(descriptor, Epoch::new(2))), + Epoch::new(2), + u8::try_from(0xab + offset).unwrap(), + &signer, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&event), + Err(IdentityError::InvalidRelationship { + resource: "retained device public-key reuse" + }) + ); + assert_eq!(state, before); + } + + let rotation = RotateDeviceKeys::new( + original_id, + device_authorization( + device_descriptor(&SecretKey::from_bytes(&[0xac; 32]), 0xad, &endpoint_secret), + Epoch::new(2), + ), + Extensions::default(), + ) + .unwrap(); + let rotation = authorized_event( + &state, + AccountOperation::RotateDeviceKeys(rotation), + Epoch::new(2), + 0xae, + &signer, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&rotation), + Err(IdentityError::InvalidRelationship { + resource: "retained device public-key reuse" + }) + ); + assert_eq!(state, before); + + let revoke = authorized_event( + &state, + AccountOperation::RevokeDevice( + RevokeDevice::new(original_id, None, Extensions::default()).unwrap(), + ), + Epoch::new(2), + 0xaf, + &signer, + ); + state.validate_and_apply(&revoke).unwrap(); + + let tombstone_reuse = authorized_event( + &state, + AccountOperation::AuthorizeDevice(device_authorization( + device_descriptor( + &application_secret, + 0xb0, + &SecretKey::from_bytes(&[0xb1; 32]), + ), + Epoch::new(3), + )), + Epoch::new(3), + 0xb1, + &signer, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&tombstone_reuse), + Err(IdentityError::InvalidRelationship { + resource: "retained device public-key reuse" + }) + ); + assert_eq!(state, before); + + let controller_reuse = authorized_event( + &state, + AccountOperation::AddController(controller(&endpoint_secret, 1)), + Epoch::new(3), + 0xb2, + &signer, + ); + assert_eq!( + state.validate_and_apply(&controller_reuse), + Err(IdentityError::InvalidRelationship { + resource: "controller/device public-key role separation" + }) + ); + assert_eq!(state, before); +} + +#[test] +fn pending_recovery_gates_unrelated_operations_and_supports_exact_cancel_or_veto() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let baseline = authorized_event( + &state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[8; 32]), 1)), + Epoch::new(1), + 10, + &signer, + ); + state.validate_and_apply(&baseline).unwrap(); + + let (begin, recovery_id) = begin_recovery_operation(&state, &signer); + let begin = authorized_event(&state, begin, Epoch::new(2), 11, &signer); + state.validate_and_apply(&begin).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::RecoveryPending); + assert_eq!(state.epoch(), Epoch::new(2)); + + let blocked = authorized_event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(3), + 12, + &signer, + ); + let pending = state.clone(); + assert_eq!( + state.validate_and_apply(&blocked), + Err(IdentityError::RecoveryPending) + ); + assert_eq!(state, pending); + + let unconfigured_provider = SecretKey::from_bytes(&[98; 32]); + let begin_proposal_id = begin.body().proposal_id().unwrap(); + let forged_anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + begin_proposal_id, + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![ + provider_receipt_signed_by( + &state, + ProviderLogSubject::EventIntent(begin_proposal_id), + 0, + 0, + 0x66, + &unconfigured_provider, + ), + recovery_provider_receipt(&state, begin_proposal_id, 100), + ]) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let forged_operation = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + forged_anchor, + Timestamp::from_unix_millis(110), + Extensions::default(), + ) + .unwrap(), + ); + let forged_body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + Epoch::new(3), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + forged_operation, + Timestamp::from_unix_millis(110), + [0x66; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x44); + let completion = ProviderReceipts::new(vec![provider_receipt( + &state, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + 110, + 0x66, + )]) + .unwrap(); + let forged_finalize = authorize_body_without_controller_approvals_with_freshness( + &state, + forged_body, + FreshnessEvidence::provider_quorum(checkpoint_id, state.provider_policy_id(), completion) + .unwrap(), + ); + let before_forged_anchor = state.clone(); + assert_eq!( + state.validate_and_apply(&forged_finalize), + Err(IdentityError::InvalidRelationship { + resource: "recovery begin observation binding", + }) + ); + assert_eq!(state, before_forged_anchor); + + let forged_configured_signature = finalize_recovery_event_with_anchor_signer( + &state, + recovery_id, + begin_proposal_id, + 0x65, + &unconfigured_provider, + ); + let before_forged_signature = state.clone(); + assert_eq!( + state.validate_and_apply(&forged_configured_signature), + Err(IdentityError::InvalidSignature) + ); + assert_eq!(state, before_forged_signature); + + let mut finalized = state.clone(); + let finalize_event = + |projected: &AccountState, anchor_head_time: u64, outer_head_time: u64, nonce: u8| { + let delay_anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + projected.account_id(), + recovery_id, + begin_proposal_id, + projected.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![provider_receipt( + projected, + ProviderLogSubject::EventIntent(begin_proposal_id), + 100, + anchor_head_time, + 0x67, + )]) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let operation = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + delay_anchor.clone(), + Timestamp::from_unix_millis(900), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + projected.account_id(), + projected.sequence().checked_next().unwrap(), + Epoch::new(3), + EventPredecessors::events(projected.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(900), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x44); + let receipts = ProviderReceipts::new(vec![provider_receipt( + projected, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + outer_head_time, + nonce, + )]) + .unwrap(); + authorize_body_without_controller_approvals_with_freshness( + projected, + body, + FreshnessEvidence::provider_quorum( + checkpoint_id, + projected.provider_policy_id(), + receipts, + ) + .unwrap(), + ) + }; + + let insufficient_nested_quorum = finalize_event(&finalized, 109, 110, 0x68); + let before_finalize = finalized.clone(); + assert_eq!( + finalized.validate_and_apply(&insufficient_nested_quorum), + Err(IdentityError::DelayNotElapsed) + ); + assert_eq!(finalized, before_finalize); + + let finalize = finalize_event(&finalized, 110, 109, 0x69); + finalized.validate_and_apply(&finalize).unwrap(); + assert_eq!(finalized.lifecycle(), ProjectionLifecycle::Active); + assert_eq!(finalized.epoch(), Epoch::new(3)); + assert_eq!(finalized.active_controllers().len(), 1); + + let threshold = RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ); + let wrong_cancel = AccountOperation::CancelRecovery( + CancelRecovery::try_new( + ProtocolVersion::V1, + typed_id::(0x52), + threshold.clone(), + FreshnessEvidence::local_known(typed_id::(0x44)), + Extensions::default(), + ) + .unwrap(), + ); + let wrong_cancel = authorized_event(&state, wrong_cancel, Epoch::new(3), 13, &signer); + assert_eq!( + state.validate_and_apply(&wrong_cancel), + Err(IdentityError::InvalidRelationship { + resource: "cancel pending recovery compare-and-set" + }) + ); + assert_eq!(state, pending); + + let mut cancelled = state.clone(); + let cancel = AccountOperation::CancelRecovery( + CancelRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + threshold, + FreshnessEvidence::local_known(typed_id::(0x44)), + Extensions::default(), + ) + .unwrap(), + ); + let cancel = authorized_event(&cancelled, cancel, Epoch::new(3), 14, &signer); + cancelled.validate_and_apply(&cancel).unwrap(); + assert_eq!(cancelled.lifecycle(), ProjectionLifecycle::Active); + assert_eq!(cancelled.epoch(), Epoch::new(3)); + + let veto = AccountOperation::VetoRecovery( + VetoRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + state.control_policy_id(), + FreshnessEvidence::local_known(typed_id::(0x44)), + Extensions::default(), + ) + .unwrap(), + ); + let veto = authorized_event(&state, veto, Epoch::new(3), 15, &signer); + state.validate_and_apply(&veto).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); + assert_eq!(state.epoch(), Epoch::new(3)); +} + +#[test] +fn recovery_anchor_quorum_and_nested_completion_cannot_be_bypassed_by_outer_receipts() { + let (genesis, signer) = fixture(); + let first_provider_secret = SecretKey::from_bytes(&[99; 32]); + let second_provider_secret = SecretKey::from_bytes(&[98; 32]); + let first_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*first_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let second_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*second_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let provider_change = authorized_event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::replicated( + ProviderPolicyVersion::new(1), + vec![first_provider.clone(), second_provider.clone()], + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(2).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(1), + 0x60, + &signer, + ); + state.validate_and_apply(&provider_change).unwrap(); + let (begin, recovery_id) = begin_recovery_operation(&state, &signer); + let begin_body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + Epoch::new(2), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + begin, + Timestamp::from_unix_millis(0x61), + [0x61; 16], + Extensions::default(), + ) + .unwrap(); + let begin_proposal_id = begin_body.proposal_id().unwrap(); + let checkpoint_id = typed_id::(0x44); + let begin_evidence = AdmissionEvidence::new( + begin_proposal_id, + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::provider_quorum( + state.provider_policy_id(), + ProviderQuorum::new(2).unwrap(), + controller_intent_approvals(&state, &begin_body, &signer), + ProviderReceipts::new(vec![ + provider_receipt_for_descriptor_signed_by( + &state, + ProviderLogSubject::EventIntent(begin_proposal_id), + 0x61, + 0x61, + 0x61, + &first_provider, + &first_provider_secret, + ), + provider_receipt_for_descriptor_signed_by( + &state, + ProviderLogSubject::EventIntent(begin_proposal_id), + 0x61, + 0x61, + 0x62, + &second_provider, + &second_provider_secret, + ), + ]) + .unwrap(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let begin = authorize_body_with_evidence(&state, begin_body, begin_evidence, &signer); + state.validate_and_apply(&begin).unwrap(); + + let build_finalize = + |projected: &AccountState, required: u16, second_nested_head: u64, nonce: u8| { + let mut nested = vec![provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::EventIntent(begin_proposal_id), + 0x61, + 110, + 0x61, + &first_provider, + &first_provider_secret, + )]; + if required > 1 { + nested.push(provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::EventIntent(begin_proposal_id), + 0x61, + second_nested_head, + 0x62, + &second_provider, + &second_provider_secret, + )); + } + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + projected.account_id(), + recovery_id, + begin_proposal_id, + projected.provider_policy_id(), + ProviderQuorum::new(required).unwrap(), + ProviderReceipts::new(nested).unwrap(), + Extensions::default(), + ) + .unwrap(); + let operation = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor, + Timestamp::from_unix_millis(110), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + projected.account_id(), + projected.sequence().checked_next().unwrap(), + Epoch::new(3), + EventPredecessors::events(projected.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(110), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x44); + let outer = ProviderReceipts::new(vec![ + provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + 110, + nonce.saturating_add(2), + &first_provider, + &first_provider_secret, + ), + provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + 110, + nonce.saturating_add(3), + &second_provider, + &second_provider_secret, + ), + ]) + .unwrap(); + authorize_body_without_controller_approvals_with_freshness( + projected, + body, + FreshnessEvidence::provider_quorum( + checkpoint_id, + projected.provider_policy_id(), + outer, + ) + .unwrap(), + ) + }; + + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&build_finalize(&state, 1, 110, 0x62)), + Err(IdentityError::FreshnessUnavailable) + ); + assert_eq!(state, before); + assert_eq!( + state.validate_and_apply(&build_finalize(&state, 2, 106, 0x66)), + Err(IdentityError::DelayNotElapsed) + ); + assert_eq!(state, before); + state + .validate_and_apply(&build_finalize(&state, 2, 107, 0x6a)) + .unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); +} + +#[test] +fn different_valid_begin_admissions_are_detectable_forks_and_bind_completion() { + let (genesis, signer) = fixture(); + let first_provider_secret = SecretKey::from_bytes(&[99; 32]); + let second_provider_secret = SecretKey::from_bytes(&[98; 32]); + let third_provider_secret = SecretKey::from_bytes(&[97; 32]); + let first_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*first_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let second_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*second_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let third_provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*third_provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + + let mut base = AccountState::from_genesis(&genesis).unwrap(); + let provider_change = authorized_event( + &base, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::replicated( + ProviderPolicyVersion::new(1), + vec![ + first_provider.clone(), + second_provider.clone(), + third_provider.clone(), + ], + ProviderQuorum::new(2).unwrap(), + ProviderQuorum::new(2).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(1), + 0x70, + &signer, + ); + base.validate_and_apply(&provider_change).unwrap(); + + let (operation, recovery_id) = begin_recovery_operation(&base, &signer); + let begin_body = EventBody::new( + base.account_id(), + base.sequence().checked_next().unwrap(), + Epoch::new(2), + EventPredecessors::events(base.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(100), + [0x71; 16], + Extensions::default(), + ) + .unwrap(); + let begin_proposal_id = begin_body.proposal_id().unwrap(); + let checkpoint_id = typed_id::(0x44); + let build_begin = |receipts: ProviderReceipts| { + let evidence = AdmissionEvidence::new( + begin_proposal_id, + checkpoint_id, + base.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::provider_quorum( + base.provider_policy_id(), + ProviderQuorum::new(2).unwrap(), + controller_intent_approvals(&base, &begin_body, &signer), + receipts, + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + authorize_body_with_evidence(&base, begin_body.clone(), evidence, &signer) + }; + let first_subset_first_receipt = || { + provider_receipt_for_descriptor_signed_by( + &base, + ProviderLogSubject::EventIntent(begin_proposal_id), + 100, + 100, + 0x71, + &first_provider, + &first_provider_secret, + ) + }; + let first_subset_second_receipt = || { + provider_receipt_for_descriptor_signed_by( + &base, + ProviderLogSubject::EventIntent(begin_proposal_id), + 100, + 100, + 0x72, + &second_provider, + &second_provider_secret, + ) + }; + let second_subset_first_receipt = || { + provider_receipt_for_descriptor_signed_by( + &base, + ProviderLogSubject::EventIntent(begin_proposal_id), + 105, + 105, + 0x73, + &first_provider, + &first_provider_secret, + ) + }; + let second_subset_third_receipt = || { + provider_receipt_for_descriptor_signed_by( + &base, + ProviderLogSubject::EventIntent(begin_proposal_id), + 105, + 105, + 0x74, + &third_provider, + &third_provider_secret, + ) + }; + let first_subset = build_begin( + ProviderReceipts::new(vec![ + first_subset_first_receipt(), + first_subset_second_receipt(), + ]) + .unwrap(), + ); + let second_subset = build_begin( + ProviderReceipts::new(vec![ + second_subset_first_receipt(), + second_subset_third_receipt(), + ]) + .unwrap(), + ); + + let mut projected_from_first_subset = base.clone(); + projected_from_first_subset + .validate_and_apply(&first_subset) + .unwrap(); + let mut projected_from_second_subset = base.clone(); + projected_from_second_subset + .validate_and_apply(&second_subset) + .unwrap(); + assert_ne!(projected_from_first_subset, projected_from_second_subset); + assert_ne!( + first_subset.event_id().unwrap(), + second_subset.event_id().unwrap() + ); + assert_ne!( + projected_from_first_subset.revision_token(), + projected_from_second_subset.revision_token() + ); + assert_eq!( + projected_from_first_subset.revision_token().heads(), + [first_subset.event_id().unwrap()] + ); + assert_eq!( + projected_from_second_subset.revision_token().heads(), + [second_subset.event_id().unwrap()] + ); + assert_ne!( + build_checkpoint_body( + &projected_from_first_subset, + Timestamp::from_unix_millis(106) + ) + .unwrap(), + build_checkpoint_body( + &projected_from_second_subset, + Timestamp::from_unix_millis(106) + ) + .unwrap() + ); + + let mut forked_first_order = projected_from_first_subset.clone(); + assert_eq!( + forked_first_order + .validate_and_apply(&second_subset) + .unwrap() + .disposition(), + ApplyDisposition::ForkDetected + ); + let mut forked_second_order = projected_from_second_subset.clone(); + assert_eq!( + forked_second_order + .validate_and_apply(&first_subset) + .unwrap() + .disposition(), + ApplyDisposition::ForkDetected + ); + assert_eq!(forked_first_order, forked_second_order); + assert_eq!(forked_first_order.lifecycle(), ProjectionLifecycle::Forked); + let mut expected_heads = vec![ + first_subset.event_id().unwrap(), + second_subset.event_id().unwrap(), + ]; + expected_heads.sort_unstable(); + assert_eq!(forked_first_order.heads(), expected_heads); + + let before_replay = projected_from_first_subset.clone(); + assert_eq!( + projected_from_first_subset + .validate_and_apply(&first_subset) + .unwrap() + .disposition(), + ApplyDisposition::Replay + ); + assert_eq!(projected_from_first_subset, before_replay); + + let build_finalize = + |projected: &AccountState, use_first_subset: bool, observed_at: u64, nonce: u8| { + let completion_at = observed_at.checked_add(10).unwrap(); + let mut receipts = Vec::with_capacity(2); + if use_first_subset { + receipts.push(provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::EventIntent(begin_proposal_id), + observed_at, + completion_at, + 0x71, + &first_provider, + &first_provider_secret, + )); + receipts.push(provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::EventIntent(begin_proposal_id), + observed_at, + completion_at, + 0x72, + &second_provider, + &second_provider_secret, + )); + } else { + receipts.push(provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::EventIntent(begin_proposal_id), + observed_at, + completion_at, + 0x73, + &first_provider, + &first_provider_secret, + )); + receipts.push(provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::EventIntent(begin_proposal_id), + observed_at, + completion_at, + 0x74, + &third_provider, + &third_provider_secret, + )); + } + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + projected.account_id(), + recovery_id, + begin_proposal_id, + projected.provider_policy_id(), + ProviderQuorum::new(2).unwrap(), + ProviderReceipts::new(receipts).unwrap(), + Extensions::default(), + ) + .unwrap(); + let operation = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor, + Timestamp::from_unix_millis(completion_at), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + projected.account_id(), + projected.sequence().checked_next().unwrap(), + Epoch::new(3), + EventPredecessors::events(projected.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(completion_at), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + let outer = ProviderReceipts::new(vec![ + provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + completion_at, + 0x78, + &first_provider, + &first_provider_secret, + ), + provider_receipt_for_descriptor_signed_by( + projected, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + completion_at, + 0x79, + &second_provider, + &second_provider_secret, + ), + ]) + .unwrap(); + authorize_body_without_controller_approvals_with_freshness( + projected, + body, + FreshnessEvidence::provider_quorum( + checkpoint_id, + projected.provider_policy_id(), + outer, + ) + .unwrap(), + ) + }; + + let alternate = build_finalize(&projected_from_first_subset, false, 105, 0x76); + let before_alternate = projected_from_first_subset.clone(); + assert_eq!( + projected_from_first_subset.validate_and_apply(&alternate), + Err(IdentityError::InvalidRelationship { + resource: "recovery begin observation binding", + }) + ); + assert_eq!(projected_from_first_subset, before_alternate); + + let exact = build_finalize(&projected_from_first_subset, true, 100, 0x77); + projected_from_first_subset + .validate_and_apply(&exact) + .unwrap(); + assert_eq!( + projected_from_first_subset.lifecycle(), + ProjectionLifecycle::Active + ); + + let alternate = build_finalize(&projected_from_second_subset, true, 100, 0x78); + let before_alternate = projected_from_second_subset.clone(); + assert_eq!( + projected_from_second_subset.validate_and_apply(&alternate), + Err(IdentityError::InvalidRelationship { + resource: "recovery begin observation binding", + }) + ); + assert_eq!(projected_from_second_subset, before_alternate); + + let exact = build_finalize(&projected_from_second_subset, false, 105, 0x79); + projected_from_second_subset + .validate_and_apply(&exact) + .unwrap(); + assert_eq!( + projected_from_second_subset.lifecycle(), + ProjectionLifecycle::Active + ); +} + +#[test] +fn cancel_recovery_requires_cancel_scope_not_begin_scope() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let begin_only_secret = SecretKey::from_bytes(&[0x5a; 32]); + let begin_only = ControllerDescriptor::new( + SigningPublicKey::ed25519(*begin_only_secret.public().as_bytes()).unwrap(), + ControllerClass::OfflineRecovery, + ControllerWeight::new(1).unwrap(), + ControllerScope::operations(vec![OperationKind::BeginRecovery]).unwrap(), + Extensions::default(), + ) + .unwrap(); + let add = authorized_event( + &state, + AccountOperation::AddController(begin_only), + Epoch::new(1), + 0x5a, + &signer, + ); + state.validate_and_apply(&add).unwrap(); + + let (begin, recovery_id) = begin_recovery_operation(&state, &signer); + let begin = authorized_event( + &state, + begin, + state.epoch().checked_next().unwrap(), + 0x5b, + &begin_only_secret, + ); + state.validate_and_apply(&begin).unwrap(); + + let cancel = AccountOperation::CancelRecovery( + CancelRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ), + FreshnessEvidence::local_known(typed_id::(0x44)), + Extensions::default(), + ) + .unwrap(), + ); + let cancel = authorized_event( + &state, + cancel, + state.epoch().checked_next().unwrap(), + 0x5c, + &begin_only_secret, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&cancel), + Err(IdentityError::IneligibleController) + ); + assert_eq!(state, before); +} + +#[test] +fn delayed_begin_recovery_uses_recovery_policy_threshold_not_control_rule_weight() { + let signer = SecretKey::from_bytes(&[0x5d; 32]); + let provider_secret = SecretKey::from_bytes(&[99; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let policy = ControlPolicy::new( + vec![ + rule(OperationKind::AddController, 1), + PolicyRule::new( + OperationKind::BeginRecovery, + RequiredWeight::new(u32::MAX).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + Some(DurationMillis::new(10)), + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x5d; 32], + Timestamp::from_unix_millis(1), + policy, + vec![controller(&signer, 1)], + recovery, + ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let baseline = authorized_event( + &state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x5e; 32]), 1)), + Epoch::new(1), + 0x5e, + &signer, + ); + state.validate_and_apply(&baseline).unwrap(); + + let (operation, _) = begin_recovery_operation(&state, &signer); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.epoch().checked_next().unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + operation, + Timestamp::from_unix_millis(110), + [0x5f; 16], + Extensions::default(), + ) + .unwrap(); + let proposal_id = body.proposal_id().unwrap(); + let signer_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|controller| controller.signing_key() == signer_key) + .unwrap() + .id(); + let intent_body = + EventIntentApprovalBody::new(controller_id, proposal_id, Extensions::default()).unwrap(); + let intent_signature = signer.sign(&intent_body.to_canonical_bytes().unwrap()); + let intent = SignedEventIntentApproval::new( + intent_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signer_key).unwrap(), + AlgorithmSignature::new(1, intent_signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + let intent_approvals = EventIntentApprovals::new(vec![intent]).unwrap(); + let admission = verify_event_intent_admission(&state, &body, &intent_approvals).unwrap(); + assert_eq!(admission.account_id(), state.account_id()); + assert_eq!( + admission.subject(), + ProviderLogSubject::EventIntent(proposal_id) + ); + let delay_receipts = ProviderReceipts::new(vec![provider_receipt( + &state, + ProviderLogSubject::EventIntent(proposal_id), + 100, + 110, + 0x5f, + )]) + .unwrap(); + let checkpoint_id = typed_id::(0x44); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::provider_quorum( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + intent_approvals, + delay_receipts, + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let begin = authorize_body_with_evidence(&state, body, evidence, &signer); + state.validate_and_apply(&begin).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::RecoveryPending); +} + +#[test] +fn guardian_recovery_requires_authenticated_provider_time_and_accepts_valid_authority() { + let guardian_secret = SecretKey::from_bytes(&[0x82; 32]); + let (genesis, controller_secret) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let baseline = authorized_event( + &state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x85; 32]), 1)), + Epoch::new(1), + 1, + &controller_secret, + ); + state.validate_and_apply(&baseline).unwrap(); + + // First install a guardian recovery policy under the original controller authority. The + // guardian leaf excludes the recovery-policy ID to avoid a circular root/ID dependency. + let blinding_bytes = [0x83; 32]; + let guardian_account_id = typed_id::(0x88); + let placeholder_policy_id = typed_id::(0x89); + let placeholder_grant = GuardianGrant::try_new( + ProtocolVersion::V1, + state.account_id(), + placeholder_policy_id, + guardian_account_id, + SigningPublicKey::ed25519(*guardian_secret.public().as_bytes()).unwrap(), + ControllerWeight::new(1).unwrap(), + Epoch::GENESIS, + Some(Timestamp::from_unix_millis(1_000)), + Extensions::default(), + ) + .unwrap(); + let guardian_set = MerkleSet::new(vec![ + placeholder_grant + .blinded_merkle_leaf(&BlindingSecret::try_new(blinding_bytes).unwrap()) + .unwrap(), + ]) + .unwrap(); + let root = GuardianSetRoot::new(guardian_set.root().unwrap()).unwrap(); + let guardian_policy = RecoveryPolicy::new( + RecoveryPolicyVersion::new(1), + RecoveryAuthority::guardian_threshold( + GuardianThreshold::new(root, 1, 1, RequiredWeight::new(1).unwrap()).unwrap(), + ), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let guardian_control_policy = ControlPolicy::new( + vec![ + provider_rule(OperationKind::BeginRecovery, 1), + provider_rule(OperationKind::CancelRecovery, 1), + provider_rule(OperationKind::FinalizeRecovery, 1), + ], + Extensions::default(), + ) + .unwrap(); + let replacement_secret = SecretKey::from_bytes(&[0x87; 32]); + let install_plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0x86; 32], + vec![controller(&replacement_secret, 1)], + guardian_control_policy, + guardian_policy.clone(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let install_proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, install_plan, Extensions::default()) + .unwrap(); + let install_recovery_id = install_proposal.recovery_id().unwrap(); + let install = AccountOperation::BeginRecovery( + BeginRecovery::try_new( + ProtocolVersion::V1, + install_proposal, + RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ), + Extensions::default(), + ) + .unwrap(), + ); + let install = authorized_event(&state, install, Epoch::new(2), 0x86, &controller_secret); + let install_proposal_id = install.body().proposal_id().unwrap(); + state.validate_and_apply(&install).unwrap(); + let finalize = finalize_recovery_event(&state, install_recovery_id, install_proposal_id, 0x87); + state.validate_and_apply(&finalize).unwrap(); + assert_eq!(state.recovery_policy_id(), guardian_policy.id().unwrap()); + + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0x88; 32], + vec![controller(&replacement_secret, 1)], + state.control_policy().clone(), + state.recovery_policy().clone(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap(); + let recovery_id = proposal.recovery_id().unwrap(); + let protected_account_id = state.account_id(); + let recovery_policy_id = state.recovery_policy_id(); + let guardian_signing_key = + SigningPublicKey::ed25519(*guardian_secret.public().as_bytes()).unwrap(); + let guardian_grant = || { + GuardianGrant::try_new( + ProtocolVersion::V1, + protected_account_id, + recovery_policy_id, + guardian_account_id, + guardian_signing_key, + ControllerWeight::new(1).unwrap(), + Epoch::GENESIS, + Some(Timestamp::from_unix_millis(1_000)), + Extensions::default(), + ) + .unwrap() + }; + let grant = guardian_grant(); + let proof = guardian_set + .inclusion_proof( + grant + .blinded_merkle_leaf(&BlindingSecret::try_new(blinding_bytes).unwrap()) + .unwrap() + .key(), + ) + .unwrap(); + let opening = GuardianGrantOpening::try_new( + ProtocolVersion::V1, + grant, + BlindingSecret::try_new(blinding_bytes).unwrap(), + root, + u16::try_from(proof.leaf_index()).unwrap(), + proof.audit_path().to_vec(), + Extensions::default(), + ) + .unwrap(); + let guardian_grant_id = opening.guardian_grant_id(); + let guardian_body = GuardianApprovalBody::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + GuardianApprovalDecision::Begin, + guardian_grant_id, + state.epoch(), + Timestamp::from_unix_millis(200), + Extensions::default(), + ) + .unwrap(); + let guardian_signature = guardian_secret.sign(&guardian_body.signing_bytes().unwrap()); + let guardian_approval = SignedGuardianApproval::try_new( + guardian_body.clone(), + opening, + ProtocolSignature::ed25519(guardian_signature.to_bytes()), + ) + .unwrap(); + let forged_signature = guardian_approval.with_signature(ProtocolSignature::ed25519([0x5a; 64])); + let build_begin_body = |approval: SignedGuardianApproval, nonce: u8| { + let threshold = RecoveryThresholdEvidence::guardian_approvals( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + GuardianApprovalSet::try_new(vec![approval]).unwrap(), + ) + .unwrap(); + let begin = AccountOperation::BeginRecovery( + BeginRecovery::try_new( + ProtocolVersion::V1, + proposal.clone(), + threshold, + Extensions::default(), + ) + .unwrap(), + ); + EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.epoch().checked_next().unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + begin, + Timestamp::from_unix_millis(200), + [nonce; 16], + Extensions::default(), + ) + .unwrap() + }; + let begin_body = build_begin_body(guardian_approval, 0x8a); + + let forged_signature_body = build_begin_body(forged_signature, 0x8b); + assert_eq!( + verify_guardian_recovery_intent_admission( + &state, + &forged_signature_body, + Timestamp::from_unix_millis(200), + ), + Err(IdentityError::InvalidSignature) + ); + + let forged_opening = GuardianGrantOpening::try_new( + ProtocolVersion::V1, + guardian_grant(), + BlindingSecret::try_new([0x84; 32]).unwrap(), + root, + 0, + Vec::new(), + Extensions::default(), + ) + .unwrap(); + let forged_membership_body = GuardianApprovalBody::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + GuardianApprovalDecision::Begin, + forged_opening.guardian_grant_id(), + state.epoch(), + Timestamp::from_unix_millis(200), + Extensions::default(), + ) + .unwrap(); + let forged_membership_signature = + guardian_secret.sign(&forged_membership_body.signing_bytes().unwrap()); + let forged_membership = SignedGuardianApproval::try_new( + forged_membership_body, + forged_opening, + ProtocolSignature::ed25519(forged_membership_signature.to_bytes()), + ) + .unwrap(); + let forged_membership_body = build_begin_body(forged_membership, 0x8c); + assert!( + verify_guardian_recovery_intent_admission( + &state, + &forged_membership_body, + Timestamp::from_unix_millis(200), + ) + .is_err() + ); + assert_eq!( + verify_guardian_recovery_intent_admission( + &state, + &begin_body, + Timestamp::from_unix_millis(199), + ), + Err(IdentityError::StaleEvidence) + ); + assert_eq!( + verify_guardian_recovery_intent_admission( + &state, + &begin_body, + Timestamp::from_unix_millis(1_000), + ), + Err(IdentityError::StaleEvidence) + ); + let begin_without_time = + authorize_body_without_controller_approvals(&state, begin_body.clone()); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&begin_without_time), + Err(IdentityError::FreshnessUnavailable) + ); + assert_eq!(state, before); + + let provider_secret = SecretKey::from_bytes(&[99; 32]); + let configured_provider = match state.provider_policy().mode() { + krikos_identity::ProviderMode::LocalOnly => panic!("fixture uses replicated providers"), + krikos_identity::ProviderMode::Replicated(policy) => policy.providers()[0].clone(), + }; + let provider_signer = TestProviderSigner(provider_secret); + let mut provider_log = + MemoryTransparencyLog::new(configured_provider, typed_id::(0x8d)); + let observed_at = Timestamp::from_unix_millis(200); + let admission = + verify_guardian_recovery_intent_admission(&state, &begin_body, observed_at).unwrap(); + assert!( + provider_log + .append( + admission.clone(), + Timestamp::from_unix_millis(201), + &provider_signer, + ) + .is_err() + ); + assert_eq!(provider_log.tree_size().unwrap(), 0); + let initial_receipt = provider_log + .append(admission, observed_at, &provider_signer) + .unwrap(); + let begin_proposal_id = begin_body.proposal_id().unwrap(); + assert_eq!( + initial_receipt.entry().subject(), + ProviderLogSubject::EventIntent(begin_proposal_id) + ); + + let checkpoint_id = typed_id::(0x44); + let freshness_receipts = ProviderReceipts::new(vec![provider_receipt( + &state, + ProviderLogSubject::Checkpoint(checkpoint_id), + 200, + 200, + 0x8a, + )]) + .unwrap(); + let begin_evidence = AdmissionEvidence::new( + begin_proposal_id, + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::provider_quorum( + checkpoint_id, + state.provider_policy_id(), + freshness_receipts, + ) + .unwrap(), + DelayEvidence::guardian_recovery( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![initial_receipt.clone()]).unwrap(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let begin = krikos_identity::AuthorizedEvent::new( + begin_body, + begin_evidence, + ControllerApprovals::new(Vec::new()).unwrap(), + ) + .unwrap(); + state.validate_and_apply(&begin).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::RecoveryPending); + + let cancel_observed_at = Timestamp::from_unix_millis(205); + let cancel_guardian_body = GuardianApprovalBody::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + GuardianApprovalDecision::Cancel, + guardian_grant_id, + state.epoch(), + cancel_observed_at, + Extensions::default(), + ) + .unwrap(); + let cancel_guardian_signature = + guardian_secret.sign(&cancel_guardian_body.signing_bytes().unwrap()); + let cancel_opening = GuardianGrantOpening::try_new( + ProtocolVersion::V1, + guardian_grant(), + BlindingSecret::try_new(blinding_bytes).unwrap(), + root, + u16::try_from(proof.leaf_index()).unwrap(), + proof.audit_path().to_vec(), + Extensions::default(), + ) + .unwrap(); + let cancel_guardian_approval = SignedGuardianApproval::try_new( + cancel_guardian_body, + cancel_opening, + ProtocolSignature::ed25519(cancel_guardian_signature.to_bytes()), + ) + .unwrap(); + let cancel_threshold = RecoveryThresholdEvidence::guardian_approvals( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + GuardianApprovalSet::try_new(vec![cancel_guardian_approval]).unwrap(), + ) + .unwrap(); + let cancel_freshness = FreshnessEvidence::provider_quorum( + checkpoint_id, + state.provider_policy_id(), + ProviderReceipts::new(vec![provider_receipt( + &state, + ProviderLogSubject::Checkpoint(checkpoint_id), + 200, + 205, + 0x90, + )]) + .unwrap(), + ) + .unwrap(); + let cancel_operation = AccountOperation::CancelRecovery( + CancelRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + cancel_threshold, + cancel_freshness.clone(), + Extensions::default(), + ) + .unwrap(), + ); + let cancel_body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.epoch().checked_next().unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + cancel_operation, + cancel_observed_at, + [0x90; 16], + Extensions::default(), + ) + .unwrap(); + assert_eq!( + verify_guardian_recovery_intent_admission( + &state, + &cancel_body, + Timestamp::from_unix_millis(204), + ), + Err(IdentityError::StaleEvidence) + ); + let cancel_admission = + verify_guardian_recovery_intent_admission(&state, &cancel_body, cancel_observed_at) + .unwrap(); + let cancel_receipt = provider_log + .append(cancel_admission, cancel_observed_at, &provider_signer) + .unwrap(); + let cancel_proposal_id = cancel_body.proposal_id().unwrap(); + assert_eq!( + cancel_receipt.entry().subject(), + ProviderLogSubject::EventIntent(cancel_proposal_id) + ); + assert_eq!( + AdmissionEvidence::new( + cancel_proposal_id, + checkpoint_id, + state.provider_policy_id(), + cancel_freshness.clone(), + DelayEvidence::guardian_recovery( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![initial_receipt.clone()]).unwrap(), + ) + .unwrap(), + Extensions::default(), + ), + Err(IdentityError::InvalidRelationship { + resource: "admission delayed proposal", + }) + ); + + let unrelated_checkpoint_only = AdmissionEvidence::new( + cancel_proposal_id, + checkpoint_id, + state.provider_policy_id(), + cancel_freshness.clone(), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let unrelated_checkpoint_only = krikos_identity::AuthorizedEvent::new( + cancel_body.clone(), + unrelated_checkpoint_only, + ControllerApprovals::new(Vec::new()).unwrap(), + ) + .unwrap(); + let mut cancelled = state.clone(); + let before_cancel = cancelled.clone(); + assert_eq!( + cancelled.validate_and_apply(&unrelated_checkpoint_only), + Err(IdentityError::FreshnessUnavailable) + ); + assert_eq!(cancelled, before_cancel); + + let cancel_evidence = AdmissionEvidence::new( + cancel_proposal_id, + checkpoint_id, + state.provider_policy_id(), + cancel_freshness, + DelayEvidence::guardian_recovery( + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![cancel_receipt]).unwrap(), + ) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let cancel = krikos_identity::AuthorizedEvent::new( + cancel_body, + cancel_evidence, + ControllerApprovals::new(Vec::new()).unwrap(), + ) + .unwrap(); + cancelled.validate_and_apply(&cancel).unwrap(); + assert_eq!(cancelled.lifecycle(), ProjectionLifecycle::Active); + + let build_finalize = + |state: &AccountState, intent_receipt: InclusionReceipt, finalized_at: u64, nonce: u8| { + let delay_anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + begin_proposal_id, + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![intent_receipt]).unwrap(), + Extensions::default(), + ) + .unwrap(); + let finalize = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + delay_anchor, + Timestamp::from_unix_millis(finalized_at), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.epoch().checked_next().unwrap(), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + finalize, + Timestamp::from_unix_millis(finalized_at), + [nonce; 16], + Extensions::default(), + ) + .unwrap(); + authorize_body_without_controller_approvals_with_freshness( + state, + body, + FreshnessEvidence::provider_quorum( + checkpoint_id, + state.provider_policy_id(), + ProviderReceipts::new(vec![provider_receipt( + state, + ProviderLogSubject::Checkpoint(checkpoint_id), + 200, + finalized_at, + nonce, + )]) + .unwrap(), + ) + .unwrap(), + ) + }; + let intent_receipt_209 = provider_log + .observe( + initial_receipt.leaf_index(), + Timestamp::from_unix_millis(209), + &provider_signer, + ) + .unwrap(); + let too_early = build_finalize(&state, intent_receipt_209, 209, 0x8e); + let pending = state.clone(); + assert_eq!( + state.validate_and_apply(&too_early), + Err(IdentityError::DelayNotElapsed) + ); + assert_eq!(state, pending); + + let intent_receipt_210 = provider_log + .observe( + initial_receipt.leaf_index(), + Timestamp::from_unix_millis(210), + &provider_signer, + ) + .unwrap(); + let finalize = build_finalize(&state, intent_receipt_210, 210, 0x8f); + state.validate_and_apply(&finalize).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); +} + +#[test] +fn recovery_cannot_reintroduce_a_revoked_controller_signing_key_under_a_new_id() { + let (genesis, signer) = fixture(); + let revoked_secret = SecretKey::from_bytes(&[0xc1; 32]); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let added_descriptor = controller(&revoked_secret, 1); + let revoked_id = added_descriptor.id().unwrap(); + let add = authorized_event( + &state, + AccountOperation::AddController(added_descriptor), + Epoch::new(1), + 0xc1, + &signer, + ); + state.validate_and_apply(&add).unwrap(); + let remove = authorized_event( + &state, + AccountOperation::RemoveController(revoked_id), + Epoch::new(2), + 0xc2, + &signer, + ); + state.validate_and_apply(&remove).unwrap(); + assert_eq!(state.revoked_controllers().len(), 1); + + let replacement_with_new_id = controller(&revoked_secret, 2); + assert_ne!(replacement_with_new_id.id().unwrap(), revoked_id); + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0xc3; 32], + vec![controller(&signer, 1), replacement_with_new_id], + state.control_policy().clone(), + state.recovery_policy().clone(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap(); + let recovery_id = proposal.recovery_id().unwrap(); + let begin = BeginRecovery::try_new( + ProtocolVersion::V1, + proposal, + RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ), + Extensions::default(), + ) + .unwrap(); + let begin = authorized_event( + &state, + AccountOperation::BeginRecovery(begin), + Epoch::new(3), + 0xc3, + &signer, + ); + let begin_proposal_id = begin.body().proposal_id().unwrap(); + state.validate_and_apply(&begin).unwrap(); + + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + state.account_id(), + recovery_id, + begin_proposal_id, + state.provider_policy_id(), + ProviderQuorum::new(1).unwrap(), + ProviderReceipts::new(vec![recovery_provider_receipt( + &state, + begin_proposal_id, + 100, + )]) + .unwrap(), + Extensions::default(), + ) + .unwrap(); + let finalize = AccountOperation::FinalizeRecovery( + FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor, + Timestamp::from_unix_millis(110), + Extensions::default(), + ) + .unwrap(), + ); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + Epoch::new(4), + EventPredecessors::events(state.heads().to_vec()).unwrap(), + finalize, + Timestamp::from_unix_millis(110), + [0xc4; 16], + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x44); + let completion = ProviderReceipts::new(vec![provider_receipt( + &state, + ProviderLogSubject::Checkpoint(checkpoint_id), + 100, + 110, + 0xc4, + )]) + .unwrap(); + let finalize = authorize_body_without_controller_approvals_with_freshness( + &state, + body, + FreshnessEvidence::provider_quorum(checkpoint_id, state.provider_policy_id(), completion) + .unwrap(), + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&finalize), + Err(IdentityError::DuplicateSigningKey) + ); + assert_eq!(state, before); +} + +#[test] +fn recovery_cannot_rebind_an_active_signing_key_to_a_new_controller_id() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let add = authorized_event( + &state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0xc5; 32]), 1)), + Epoch::new(1), + 0xc5, + &signer, + ); + state.validate_and_apply(&add).unwrap(); + + let original_id = controller(&signer, 1).id().unwrap(); + let rebound = controller(&signer, 2); + assert_ne!(rebound.id().unwrap(), original_id); + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0xc6; 32], + vec![rebound], + state.control_policy().clone(), + state.recovery_policy().clone(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap(); + let recovery_id = proposal.recovery_id().unwrap(); + let begin = AccountOperation::BeginRecovery( + BeginRecovery::try_new( + ProtocolVersion::V1, + proposal, + RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ), + Extensions::default(), + ) + .unwrap(), + ); + let begin = authorized_event( + &state, + begin, + state.epoch().checked_next().unwrap(), + 0xc6, + &signer, + ); + let begin_proposal_id = begin.body().proposal_id().unwrap(); + state.validate_and_apply(&begin).unwrap(); + + let finalize = finalize_recovery_event(&state, recovery_id, begin_proposal_id, 0xc7); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&finalize), + Err(IdentityError::DuplicateSigningKey) + ); + assert_eq!(state, before); +} + +#[test] +fn dual_suite_checkpoint_requires_complete_old_and_new_controller_signatures() { + let (genesis, original_secret) = fixture(); + let migrated_secret = SecretKey::from_bytes(&[0xcf; 32]); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let original_suite = CryptoSuiteDescriptor::v1().unwrap(); + let migrated_suite = in_place_suite(2); + let controller_id = state.active_controllers()[0].id(); + + let (begin, migration_id) = begin_migration_event( + &state, + &original_suite, + &original_secret, + migrated_suite.clone(), + &migrated_secret, + 0xcf, + ); + let begin_event_id = begin.event_id().unwrap(); + state.validate_and_apply(&begin).unwrap(); + let activate = authorized_event_with_crypto_keys( + &state, + AccountOperation::ActivateCryptoMigration( + ActivateCryptoMigration::try_new( + ProtocolVersion::V1, + migration_id, + begin_event_id, + Extensions::default(), + ) + .unwrap(), + ), + state.epoch().checked_next().unwrap(), + 0xd0, + controller_id, + &[(&original_suite, &original_secret, false)], + ); + state.validate_and_apply(&activate).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::MigrationDual); + + let body = build_checkpoint_body(&state, Timestamp::from_unix_millis(300)).unwrap(); + let incomplete = signed_checkpoint_with_crypto_keys( + body.clone(), + controller_id, + &[(&original_suite, &original_secret, false)], + ); + let before = state.clone(); + assert_eq!( + verify_checkpoint(&state, &incomplete, None), + Err(IdentityError::InvalidSignature) + ); + assert_eq!(state, before); + + let complete = signed_checkpoint_with_crypto_keys( + body, + controller_id, + &[ + (&original_suite, &original_secret, false), + (&migrated_suite, &migrated_secret, true), + ], + ); + let verified = verify_checkpoint(&state, &complete, None).unwrap(); + assert_eq!(verified.checkpoint_id(), complete.checkpoint_id().unwrap()); + assert_eq!(state, before); +} + +#[test] +fn post_migration_controller_additions_receive_current_suite_verification_keys() { + let (genesis, original_secret) = fixture(); + let migrated_secret = SecretKey::from_bytes(&[0xd0; 32]); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let migrated_suite = + migrate_to_in_place_ed25519_suite(&mut state, &original_secret, &migrated_secret); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); + + let original_controller_id = state.active_controllers()[0].id(); + let added_secret = SecretKey::from_bytes(&[0xd4; 32]); + let added_descriptor = controller(&added_secret, 1); + let added_controller_id = added_descriptor.id().unwrap(); + let add = authorized_event_with_crypto_keys( + &state, + AccountOperation::AddController(added_descriptor), + state.epoch().checked_next().unwrap(), + 0xd4, + original_controller_id, + &[(&migrated_suite, &migrated_secret, true)], + ); + state.validate_and_apply(&add).unwrap(); + + let remove_original = authorized_event_with_crypto_keys( + &state, + AccountOperation::RemoveController(original_controller_id), + state.epoch().checked_next().unwrap(), + 0xd5, + added_controller_id, + &[(&migrated_suite, &added_secret, true)], + ); + state.validate_and_apply(&remove_original).unwrap(); + assert_eq!(state.active_controllers().len(), 1); + assert_eq!(state.active_controllers()[0].id(), added_controller_id); +} + +#[test] +fn retired_crypto_suites_and_keys_are_permanent_tombstones() { + let (genesis, original_secret) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let v1 = CryptoSuiteDescriptor::v1().unwrap(); + let suite2 = in_place_suite(2); + let suite2_secret = SecretKey::from_bytes(&[0x42; 32]); + complete_in_place_migration( + &mut state, + &v1, + &original_secret, + &suite2, + &suite2_secret, + 0x41, + ); + + let downgrade_secret = SecretKey::from_bytes(&[0x43; 32]); + let (downgrade, _) = begin_migration_event( + &state, + &suite2, + &suite2_secret, + v1.clone(), + &downgrade_secret, + 0x44, + ); + let before_downgrade = state.clone(); + assert_eq!( + state.validate_and_apply(&downgrade), + Err(IdentityError::InvalidRelationship { + resource: "retired cryptographic suite reuse" + }) + ); + assert_eq!(state, before_downgrade); + + let suite3 = in_place_suite(3); + let suite3_secret = SecretKey::from_bytes(&[0x45; 32]); + complete_in_place_migration( + &mut state, + &suite2, + &suite2_secret, + &suite3, + &suite3_secret, + 0x45, + ); + + let suite4 = in_place_suite(4); + let (reused_retired_key, _) = begin_migration_event( + &state, + &suite3, + &suite3_secret, + suite4, + &suite2_secret, + 0x48, + ); + let before_reuse = state.clone(); + assert_eq!( + state.validate_and_apply(&reused_retired_key), + Err(IdentityError::DuplicateSigningKey) + ); + assert_eq!(state, before_reuse); + + let controller_id = state.active_controllers()[0].id(); + let authorize_reused_device = authorized_event_with_crypto_keys( + &state, + AccountOperation::AuthorizeDevice(device_authorization( + device_descriptor(&suite2_secret, 0x49, &SecretKey::from_bytes(&[0x4a; 32])), + state.epoch().checked_next().unwrap(), + )), + state.epoch().checked_next().unwrap(), + 0x49, + controller_id, + &[(&suite3, &suite3_secret, true)], + ); + assert_eq!( + state.validate_and_apply(&authorize_reused_device), + Err(IdentityError::InvalidRelationship { + resource: "device/cryptographic key tombstone separation" + }) + ); + assert_eq!(state, before_reuse); + + let add_reused_controller = authorized_event_with_crypto_keys( + &state, + AccountOperation::AddController(controller(&suite2_secret, 1)), + state.epoch().checked_next().unwrap(), + 0x4a, + controller_id, + &[(&suite3, &suite3_secret, true)], + ); + assert_eq!( + state.validate_and_apply(&add_reused_controller), + Err(IdentityError::DuplicateSigningKey) + ); + assert_eq!(state, before_reuse); +} + +#[test] +fn recovery_fails_closed_under_a_migrated_stable_suite() { + let (genesis, original_secret) = fixture(); + let migrated_secret = SecretKey::from_bytes(&[0xe0; 32]); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let migrated_suite = + migrate_to_in_place_ed25519_suite(&mut state, &original_secret, &migrated_secret); + let controller_id = state.active_controllers()[0].id(); + + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + state.account_id(), + typed_id::(0x44), + state.heads()[0], + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + [0xe1; 32], + vec![state.active_controllers()[0].descriptor().clone()], + state.control_policy().clone(), + state.recovery_policy().clone(), + Vec::new(), + Timestamp::from_unix_millis(1_000), + Extensions::default(), + ) + .unwrap(); + let proposal = + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap(); + let begin = AccountOperation::BeginRecovery( + BeginRecovery::try_new( + ProtocolVersion::V1, + proposal, + RecoveryThresholdEvidence::controller_policy( + state.recovery_policy_id(), + state.recovery_policy().policy_version(), + ), + Extensions::default(), + ) + .unwrap(), + ); + let begin = authorized_event_with_crypto_keys( + &state, + begin, + state.epoch().checked_next().unwrap(), + 0xe1, + controller_id, + &[(&migrated_suite, &migrated_secret, true)], + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&begin), + Err(IdentityError::UnsupportedPolicyFeature { + feature: "recovery under a migrated cryptographic suite", + }) + ); + assert_eq!(state, before); +} + +#[test] +fn identical_body_replay_is_idempotent_and_not_a_fork() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let added_secret = SecretKey::from_bytes(&[8; 32]); + let event = authorized_event( + &state, + AccountOperation::AddController(controller(&added_secret, 1)), + Epoch::new(1), + 4, + &signer, + ); + let event_id = event.event_id().unwrap(); + + assert_eq!( + state.validate_and_apply(&event).unwrap().disposition(), + ApplyDisposition::Applied + ); + let stable = state.clone(); + let replay = state.validate_and_apply(&event).unwrap(); + assert_eq!(replay.disposition(), ApplyDisposition::Replay); + assert_eq!(replay.event_id(), event_id); + assert_eq!(state, stable); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); +} + +#[test] +fn valid_conflicting_bodies_are_retained_without_branch_selection() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let base = state.clone(); + let left = authorized_event( + &base, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[8; 32]), 1)), + Epoch::new(1), + 5, + &signer, + ); + let right = authorized_event( + &base, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[9; 32]), 1)), + Epoch::new(1), + 6, + &signer, + ); + state.validate_and_apply(&left).unwrap(); + + let outcome = state.validate_and_apply(&right).unwrap(); + let mut expected = vec![left.event_id().unwrap(), right.event_id().unwrap()]; + expected.sort_unstable(); + assert_eq!(outcome.disposition(), ApplyDisposition::ForkDetected); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Forked); + assert_eq!(state.heads(), expected); + assert_eq!(state.active_controllers().len(), 1); +} + +#[test] +fn fork_branches_accept_descendants_replace_tips_and_converge_by_arrival_order() { + let (genesis, signer) = fixture(); + let base = AccountState::from_genesis(&genesis).unwrap(); + let left = authorized_event( + &base, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x81; 32]), 1)), + Epoch::new(1), + 0x81, + &signer, + ); + let right = authorized_event( + &base, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x82; 32]), 1)), + Epoch::new(1), + 0x82, + &signer, + ); + + let mut left_projection = base.clone(); + left_projection.validate_and_apply(&left).unwrap(); + let left_descendant = authorized_event( + &left_projection, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 0x83, + &signer, + ); + + let mut late_conflict = base.clone(); + late_conflict.validate_and_apply(&left).unwrap(); + late_conflict.validate_and_apply(&left_descendant).unwrap(); + late_conflict.validate_and_apply(&right).unwrap(); + + let mut fork_first = base.clone(); + fork_first.validate_and_apply(&left).unwrap(); + fork_first.validate_and_apply(&right).unwrap(); + fork_first.validate_and_apply(&left_descendant).unwrap(); + assert_eq!(fork_first, late_conflict); + let mut expected_heads = vec![ + left_descendant.event_id().unwrap(), + right.event_id().unwrap(), + ]; + expected_heads.sort_unstable(); + assert_eq!(fork_first.heads(), expected_heads); + assert_eq!(fork_first.sequence(), Sequence::new(2)); + + let mut right_projection = base; + right_projection.validate_and_apply(&right).unwrap(); + let right_descendant = authorized_event( + &right_projection, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 0x84, + &signer, + ); + right_projection + .validate_and_apply(&right_descendant) + .unwrap(); + let right_grandchild = authorized_event( + &right_projection, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(2), Extensions::default()) + .unwrap(), + ), + Epoch::new(3), + 0x85, + &signer, + ); + fork_first.validate_and_apply(&right_descendant).unwrap(); + fork_first.validate_and_apply(&right_grandchild).unwrap(); + let mut unequal_heads = vec![ + left_descendant.event_id().unwrap(), + right_grandchild.event_id().unwrap(), + ]; + unequal_heads.sort_unstable(); + assert_eq!(fork_first.heads(), unequal_heads); + assert_eq!(fork_first.sequence(), Sequence::new(3)); +} + +#[test] +fn frozen_epoch_table_covers_every_v1_operation_kind() { + let advancing = [ + OperationKind::AuthorizeDevice, + OperationKind::UpdateDeviceAuthorization, + OperationKind::SuspendDevice, + OperationKind::ReinstateDevice, + OperationKind::RevokeDevice, + OperationKind::RotateDeviceKeys, + OperationKind::AddController, + OperationKind::RemoveController, + OperationKind::ChangeControlPolicy, + OperationKind::ChangeRecoveryPolicy, + OperationKind::ChangeProviderPolicy, + OperationKind::BeginRecovery, + OperationKind::VetoRecovery, + OperationKind::CancelRecovery, + OperationKind::FinalizeRecovery, + OperationKind::ResolveFork, + OperationKind::ActivateCryptoMigration, + OperationKind::UpgradeProtocol, + OperationKind::RetireAccount, + ]; + for kind in advancing { + assert_eq!( + AccountState::operation_kind_advances_epoch(kind), + Some(true) + ); + } + assert_eq!( + AccountState::operation_kind_advances_epoch(OperationKind::UpdateDeviceMetadata), + Some(false) + ); + assert_eq!( + AccountState::operation_kind_advances_epoch(OperationKind::BeginCryptoMigration), + Some(false) + ); + assert_eq!( + AccountState::operation_kind_advances_epoch(OperationKind::RetireCryptoSuite), + None + ); + assert_eq!(advancing.len() + 3, 22); + + let (genesis, _) = fixture(); + let state = AccountState::from_genesis(&genesis).unwrap(); + let abort = AccountOperation::RetireCryptoSuite( + RetireCryptoSuite::try_new( + ProtocolVersion::V1, + typed_id::(0x70), + RetireCryptoSuiteMode::AbortCandidate, + typed_id::(0x71), + None, + Extensions::default(), + ) + .unwrap(), + ); + let retire_previous = AccountOperation::RetireCryptoSuite( + RetireCryptoSuite::try_new( + ProtocolVersion::V1, + typed_id::(0x72), + RetireCryptoSuiteMode::RetirePrevious, + typed_id::(0x73), + None, + Extensions::default(), + ) + .unwrap(), + ); + assert_eq!(state.expected_epoch_for(&abort).unwrap(), Epoch::GENESIS); + assert_eq!( + state.expected_epoch_for(&retire_previous).unwrap(), + Epoch::new(1) + ); +} + +#[test] +fn late_conflict_at_a_retained_ancestor_reopens_a_fork() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let baseline = authorized_event( + &state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[8; 32]), 1)), + Epoch::new(1), + 20, + &signer, + ); + state.validate_and_apply(&baseline).unwrap(); + let divergence = state.clone(); + + let accepted = authorized_event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 21, + &signer, + ); + state.validate_and_apply(&accepted).unwrap(); + let descendant = authorized_event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(2), Extensions::default()) + .unwrap(), + ), + Epoch::new(3), + 22, + &signer, + ); + state.validate_and_apply(&descendant).unwrap(); + + let late = authorized_event( + &divergence, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 23, + &signer, + ); + assert_eq!( + state.validate_and_apply(&late).unwrap().disposition(), + ApplyDisposition::ForkDetected + ); + let mut heads = vec![descendant.event_id().unwrap(), late.event_id().unwrap()]; + heads.sort_unstable(); + assert_eq!(state.heads(), heads); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Forked); + assert_eq!( + state.active_controllers().len(), + divergence.active_controllers().len() + ); + + let descriptor = ForkDescriptor::try_new( + ProtocolVersion::V1, + state.account_id(), + ForkCommonAncestor::Event(baseline.event_id().unwrap()), + state.heads().to_vec(), + Extensions::default(), + ) + .unwrap(); + let resolution = authorized_event( + &state, + AccountOperation::ResolveFork( + ResolveFork::try_new( + ProtocolVersion::V1, + descriptor, + late.event_id().unwrap(), + Vec::new(), + Vec::new(), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(4), + 24, + &signer, + ); + state.validate_and_apply(&resolution).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); + assert_eq!(state.epoch(), Epoch::new(4)); + assert_eq!( + state.validate_and_apply(&resolution).unwrap().disposition(), + ApplyDisposition::Replay + ); +} + +#[test] +fn first_event_fork_is_resolvable_from_the_genesis_anchor() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let genesis_state = state.clone(); + let left = authorized_event( + &genesis_state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0xf1; 32]), 1)), + Epoch::new(1), + 0xf1, + &signer, + ); + let right = authorized_event( + &genesis_state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0xf2; 32]), 1)), + Epoch::new(1), + 0xf2, + &signer, + ); + state.validate_and_apply(&left).unwrap(); + assert_eq!( + state.validate_and_apply(&right).unwrap().disposition(), + ApplyDisposition::ForkDetected + ); + let fork = ForkDescriptor::try_new( + ProtocolVersion::V1, + state.account_id(), + ForkCommonAncestor::Genesis(state.genesis_anchor()), + state.heads().to_vec(), + Extensions::default(), + ) + .unwrap(); + let resolution = authorized_event( + &state, + AccountOperation::ResolveFork( + ResolveFork::try_new( + ProtocolVersion::V1, + fork, + left.event_id().unwrap(), + Vec::new(), + Vec::new(), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(2), + 0xf3, + &signer, + ); + state.validate_and_apply(&resolution).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); + assert_eq!(state.epoch(), Epoch::new(2)); + assert_eq!(state.active_controllers().len(), 2); +} + +#[test] +fn explicit_fork_resolution_selects_a_branch_and_competing_resolution_reopens() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let baseline = authorized_event( + &state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[8; 32]), 1)), + Epoch::new(1), + 30, + &signer, + ); + let common_ancestor = baseline.event_id().unwrap(); + state.validate_and_apply(&baseline).unwrap(); + let divergence = state.clone(); + let left = authorized_event( + &divergence, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 31, + &signer, + ); + let right = authorized_event( + &divergence, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 32, + &signer, + ); + state.validate_and_apply(&left).unwrap(); + state.validate_and_apply(&right).unwrap(); + let forked = state.clone(); + let descriptor = ForkDescriptor::try_new( + ProtocolVersion::V1, + state.account_id(), + ForkCommonAncestor::Event(common_ancestor), + state.heads().to_vec(), + Extensions::default(), + ) + .unwrap(); + let resolve_left = authorized_event( + &forked, + AccountOperation::ResolveFork( + ResolveFork::try_new( + ProtocolVersion::V1, + descriptor.clone(), + left.event_id().unwrap(), + Vec::new(), + Vec::new(), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(3), + 33, + &signer, + ); + let resolve_right = authorized_event( + &forked, + AccountOperation::ResolveFork( + ResolveFork::try_new( + ProtocolVersion::V1, + descriptor, + right.event_id().unwrap(), + Vec::new(), + Vec::new(), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(3), + 34, + &signer, + ); + state.validate_and_apply(&resolve_left).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Active); + assert_eq!(state.epoch(), Epoch::new(3)); + assert_eq!( + state + .validate_and_apply(&resolve_right) + .unwrap() + .disposition(), + ApplyDisposition::ForkDetected + ); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Forked); + let mut resolution_heads = vec![ + resolve_left.event_id().unwrap(), + resolve_right.event_id().unwrap(), + ]; + resolution_heads.sort_unstable(); + assert_eq!(state.heads(), resolution_heads); +} + +#[test] +fn retirement_is_terminal_but_identical_replay_remains_idempotent() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let retirement = authorized_event( + &state, + AccountOperation::RetireAccount( + RetireAccount::try_new(ProtocolVersion::V1, None, None, Extensions::default()).unwrap(), + ), + Epoch::new(1), + 40, + &signer, + ); + state.validate_and_apply(&retirement).unwrap(); + assert_eq!(state.lifecycle(), ProjectionLifecycle::Retired); + assert_eq!( + state.validate_and_apply(&retirement).unwrap().disposition(), + ApplyDisposition::Replay + ); + + let later = authorized_event( + &state, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[9; 32]), 1)), + Epoch::new(2), + 41, + &signer, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&later), + Err(IdentityError::AccountRetired) + ); + assert_eq!(state, before); +} + +#[test] +fn generated_linear_history_matches_a_small_reference_model() { + let (genesis, signer) = fixture(); + let mut state = AccountState::from_genesis(&genesis).unwrap(); + let mut model_sequence = 0_u64; + let mut model_epoch = 0_u64; + for version in 1_u64..=32 { + let event = authorized_event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only( + ProviderPolicyVersion::new(version), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(model_epoch + 1), + u8::try_from(version).unwrap(), + &signer, + ); + let event_id = event.event_id().unwrap(); + state.validate_and_apply(&event).unwrap(); + model_sequence += 1; + model_epoch += 1; + assert_eq!(state.sequence(), Sequence::new(model_sequence)); + assert_eq!(state.epoch(), Epoch::new(model_epoch)); + assert_eq!(state.heads(), [event_id]); + assert_eq!(state.revision_token().heads(), [event_id]); + } +} + +#[test] +fn evicted_lineage_requests_authenticated_history_instead_of_losing_late_branches() { + let (genesis, signer) = fixture(); + let origin = AccountState::from_genesis(&genesis).unwrap(); + let mut state = origin.clone(); + let mut first_accepted = None; + for version in 1_u64..=257 { + let nonce = u8::try_from(((version - 1) % 254) + 1).unwrap(); + let event = authorized_event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::replicated( + ProviderPolicyVersion::new(version), + state.provider_policy().providers().unwrap().to_vec(), + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(version), + nonce, + &signer, + ); + if version == 1 { + first_accepted = Some(event.clone()); + } + state.validate_and_apply(&event).unwrap(); + } + + let alternate = authorized_event( + &origin, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x92; 32]), 1)), + Epoch::new(1), + 0x91, + &signer, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&alternate), + Err(IdentityError::HistoricalStateRequired { sequence: 1 }) + ); + assert_eq!(state, before); + + assert!(first_accepted.is_some()); +} + +#[test] +fn lineage_byte_budget_evicts_validation_snapshots_before_the_event_count_cap() { + let (genesis, signer) = fixture(); + let origin = AccountState::from_genesis(&genesis).unwrap(); + let mut state = origin.clone(); + let first = authorized_event( + &state, + AccountOperation::AddController(large_controller(&SecretKey::from_bytes(&[0x93; 32]))), + Epoch::new(1), + 0x93, + &signer, + ); + state.validate_and_apply(&first).unwrap(); + for version in 1_u64..=80 { + let event = authorized_event( + &state, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only( + ProviderPolicyVersion::new(version), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(version + 1), + u8::try_from(version).unwrap(), + &signer, + ); + state.validate_and_apply(&event).unwrap(); + } + assert!(state.sequence().get() < 256); + + let alternate = authorized_event( + &origin, + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x94; 32]), 1)), + Epoch::new(1), + 0x94, + &signer, + ); + let before = state.clone(); + assert_eq!( + state.validate_and_apply(&alternate), + Err(IdentityError::HistoricalStateRequired { sequence: 1 }) + ); + assert_eq!(state, before); +} + +#[test] +fn fork_budget_counts_retained_branch_validation_states_not_only_tip_events() { + let (genesis, signer) = fixture(); + let mut common = AccountState::from_genesis(&genesis).unwrap(); + let large = authorized_event( + &common, + AccountOperation::AddController(large_controller(&SecretKey::from_bytes(&[0x95; 32]))), + Epoch::new(1), + 0x95, + &signer, + ); + common.validate_and_apply(&large).unwrap(); + let left = authorized_event( + &common, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 0x96, + &signer, + ); + let right = authorized_event( + &common, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Epoch::new(2), + 0x97, + &signer, + ); + let mut branch_projection = common.clone(); + branch_projection.validate_and_apply(&left).unwrap(); + let mut forked = common; + forked.validate_and_apply(&left).unwrap(); + forked.validate_and_apply(&right).unwrap(); + + let mut bounded_rejection = None; + for version in 2_u64..=100 { + let event = authorized_event( + &branch_projection, + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only( + ProviderPolicyVersion::new(version), + Extensions::default(), + ) + .unwrap(), + ), + Epoch::new(version + 1), + u8::try_from(version).unwrap(), + &signer, + ); + branch_projection.validate_and_apply(&event).unwrap(); + let before = forked.clone(); + match forked.validate_and_apply(&event) { + Ok(_) => {} + Err(error) => { + assert_eq!(forked, before); + bounded_rejection = Some(error); + break; + } + } + } + assert!(matches!( + bounded_rejection, + Some(IdentityError::LimitExceeded { + resource: "account fork evidence bytes", + .. + }) + )); +} diff --git a/protocols/krikos-identity/tests/store_conformance.rs b/protocols/krikos-identity/tests/store_conformance.rs new file mode 100644 index 00000000000..e6d72859938 --- /dev/null +++ b/protocols/krikos-identity/tests/store_conformance.rs @@ -0,0 +1,1258 @@ +use futures_lite::future::block_on; +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, AccountStore, AdmissionEvidence, + AlgorithmSignature, ApplyDisposition, CanonicalWire, CheckpointAuthorization, CheckpointId, + ClaimEffects, ControlPolicy, ControllerApprovalBody, ControllerApprovals, ControllerClass, + ControllerDescriptor, ControllerKeyId, ControllerScope, ControllerSelector, + ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, DelayEvidence, Digest, + DurationMillis, EffectFailure, EffectStatus, EventBody, EventPredecessors, Extensions, + FreshnessEvidence, FreshnessRequirement, HashAlgorithm, KeyedSignature, LeaseId, + MemoryAccountStore, MemoryOperationalEffectStore, OperationKind, OperationalEffectJournal, + OperationalEffectPhase, PolicyRule, ProjectionEffect, ProviderPolicy, ProviderPolicyVersion, + RecoveryAuthority, RecoveryPolicy, RecoveryPolicyVersion, RequiredWeight, Sequence, + SignedCheckpoint, SignedControllerApproval, SigningPublicKey, SyncFrame, SyncRequest, + Timestamp, VerifiedCheckpoint, build_checkpoint_body, complete_ready_effect, + reconcile_sync_frame, serve_sync_request, verify_checkpoint, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn genesis() -> AccountGenesis { + let secret = SecretKey::from_bytes(&[7; 32]); + let controller = controller(&secret); + let rules = [ + OperationKind::AddController, + OperationKind::ChangeProviderPolicy, + ] + .into_iter() + .map(|operation| { + PolicyRule::new( + operation, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap() + }) + .collect(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + AccountGenesis::new( + [1; 32], + Timestamp::from_unix_millis(1), + ControlPolicy::new(rules, Extensions::default()).unwrap(), + vec![controller], + recovery, + ProviderPolicy::local_only(ProviderPolicyVersion::GENESIS, Extensions::default()).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +fn authorized_add_controller( + state: &AccountState, + signer: &SecretKey, + added: &SecretKey, + nonce: u8, +) -> krikos_identity::AuthorizedEvent { + authorized_operation( + state, + signer, + AccountOperation::AddController(controller(added)), + u64::from(nonce), + ) +} + +fn authorized_operation( + state: &AccountState, + signer: &SecretKey, + operation: AccountOperation, + nonce: u64, +) -> krikos_identity::AuthorizedEvent { + let predecessors = if state.sequence() == Sequence::GENESIS { + EventPredecessors::genesis(state.genesis_anchor()) + } else { + EventPredecessors::events(state.heads().to_vec()).unwrap() + }; + let nonce_bytes = nonce.to_le_bytes().repeat(2); + let nonce_bytes: [u8; 16] = nonce_bytes.try_into().unwrap(); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.expected_epoch_for(&operation).unwrap(), + predecessors, + operation, + Timestamp::from_unix_millis(nonce), + nonce_bytes, + Extensions::default(), + ) + .unwrap(); + let checkpoint_id = typed_id::(0x44); + let evidence = AdmissionEvidence::new( + body.proposal_id().unwrap(), + checkpoint_id, + state.provider_policy_id(), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|candidate| candidate.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = ControllerApprovalBody::event( + controller_id, + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + krikos_identity::AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn verified_checkpoint( + state: &AccountState, + signer: &SecretKey, + issued_at: Timestamp, +) -> VerifiedCheckpoint { + let body = build_checkpoint_body(state, issued_at).unwrap(); + let checkpoint_id = body.checkpoint_id().unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let controller_id = state + .active_controllers() + .iter() + .find(|candidate| candidate.signing_key() == signing_key) + .unwrap() + .id(); + let approval_body = + ControllerApprovalBody::checkpoint(controller_id, checkpoint_id, Extensions::default()) + .unwrap(); + let signature = signer.sign(&approval_body.to_canonical_bytes().unwrap()); + let approval = SignedControllerApproval::new( + approval_body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap(); + let signed = SignedCheckpoint::new( + body, + CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + verify_checkpoint(state, &signed, None).unwrap() +} + +#[test] +fn memory_store_create_load_reconstructs_projection_and_distinguishes_absence() { + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let expected = AccountState::from_genesis(&genesis) + .unwrap() + .revision_token(); + let store = MemoryAccountStore::new(); + + assert!(block_on(store.load_account(account_id)).unwrap().is_none()); + let created = block_on(store.create_account(genesis.clone())).unwrap(); + assert_eq!(created.revision(), &expected); + + let loaded = block_on(store.load_account(account_id)) + .unwrap() + .expect("created account must be present"); + assert_eq!(loaded.genesis(), &genesis); + assert_eq!(loaded.state().revision_token(), expected); + assert!(loaded.events().is_empty()); + assert!(loaded.checkpoints().is_empty()); + assert!(loaded.fork_evidence().is_empty()); +} + +#[test] +fn memory_commit_is_exact_cas_and_event_plus_outbox_are_atomic_and_idempotent() { + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let signer = SecretKey::from_bytes(&[7; 32]); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let first = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[8; 32]), + 1, + ); + + let committed = + block_on(store.commit_event(initial.revision().clone(), first.clone())).unwrap(); + assert_eq!(committed.outcome().disposition(), ApplyDisposition::Applied); + assert_eq!(committed.snapshot().events(), std::slice::from_ref(&first)); + assert_eq!(committed.snapshot().outbox().len(), 3); + assert!( + committed + .snapshot() + .outbox() + .iter() + .all(|effect| effect.status() == EffectStatus::Pending) + ); + + let second = authorized_add_controller( + committed.snapshot().state(), + &signer, + &SecretKey::from_bytes(&[9; 32]), + 2, + ); + assert_eq!( + block_on(store.commit_event(initial.revision().clone(), second)), + Err(krikos_identity::IdentityError::StaleRevision) + ); + + let unchanged = block_on(store.load_account(account_id)).unwrap().unwrap(); + assert_eq!(unchanged.revision(), committed.snapshot().revision()); + assert_eq!(unchanged.events().len(), 1); + assert_eq!(unchanged.outbox().len(), 3); + + let replay = block_on(store.commit_event(unchanged.revision().clone(), first)).unwrap(); + assert_eq!(replay.outcome().disposition(), ApplyDisposition::Replay); + assert_eq!(replay.snapshot().outbox().len(), 3); +} + +#[test] +fn event_history_is_bounded_and_frozen_to_the_exact_source_revision() { + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let signer = SecretKey::from_bytes(&[7; 32]); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let first = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[8; 32]), + 1, + ); + let after_first = block_on(store.commit_event(initial.revision().clone(), first.clone())) + .unwrap() + .snapshot() + .clone(); + let frozen = after_first.revision().clone(); + let second = authorized_add_controller( + after_first.state(), + &signer, + &SecretKey::from_bytes(&[9; 32]), + 2, + ); + let current = block_on(store.commit_event(after_first.revision().clone(), second.clone())) + .unwrap() + .snapshot() + .clone(); + + let frozen_page = + block_on(store.event_history(frozen.clone(), None, 1, 4 * 1024 * 1024)).unwrap(); + assert_eq!(frozen_page.source_revision(), &frozen); + assert_eq!(frozen_page.records().len(), 1); + assert_eq!(frozen_page.records()[0].cursor(), 0); + assert_eq!(frozen_page.records()[0].event(), &first); + assert!(frozen_page.next_cursor().is_none()); + + let first_page = + block_on(store.event_history(current.revision().clone(), None, 1, 4 * 1024 * 1024)) + .unwrap(); + assert_eq!(first_page.records()[0].event(), &first); + let first_cursor = first_page.next_cursor().unwrap().clone(); + assert_eq!(first_cursor.source_revision(), current.revision()); + assert_eq!(first_cursor.position(), 0); + let second_page = block_on(store.event_history( + current.revision().clone(), + Some(first_cursor.clone()), + 1, + 4 * 1024 * 1024, + )) + .unwrap(); + assert_eq!(second_page.records()[0].cursor(), 1); + assert_eq!(second_page.records()[0].event(), &second); + assert!(second_page.next_cursor().is_none()); + + assert_eq!( + block_on(store.event_history(frozen.clone(), Some(first_cursor), 1, 1024)), + Err(krikos_identity::IdentityError::InvalidRelationship { + resource: "account event-history cursor revision", + }) + ); + assert!(matches!( + block_on(store.event_history(current.revision().clone(), None, 1, 1)), + Err(krikos_identity::IdentityError::LimitExceeded { + resource: "account event-history bytes", + .. + }) + )); + + let other = MemoryAccountStore::new(); + let other_revision = block_on( + other.create_account( + AccountGenesis::new( + [2; 32], + Timestamp::from_unix_millis(2), + initial.genesis().initial_policy().clone(), + initial.genesis().initial_controllers().to_vec(), + initial.genesis().initial_recovery_policy().clone(), + initial.genesis().initial_provider_policy().clone(), + Extensions::default(), + ) + .unwrap(), + ), + ) + .unwrap() + .revision() + .clone(); + assert_eq!( + block_on(store.event_history(other_revision, None, 1, 1024)), + Err(krikos_identity::IdentityError::InvalidRelationship { + resource: "account store missing account", + }) + ); + assert_eq!(account_id, current.revision().account_id()); + + let cursor_key = krikos_identity::CursorKey::new([0x44; 32]).unwrap(); + let first_request = SyncRequest::new(account_id, Vec::new(), None, 1, 4 * 1024 * 1024).unwrap(); + let first_response = block_on(serve_sync_request(&store, &cursor_key, &first_request)).unwrap(); + let first_frame = first_response.as_frame().unwrap(); + assert_eq!(first_frame.events(), std::slice::from_ref(&first)); + let continuation = first_frame.continuation().unwrap().clone(); + assert_eq!(continuation.source_heads(), current.revision().heads()); + assert_eq!( + usize::try_from(continuation.delivered_bytes()).unwrap(), + first_request.to_canonical_bytes().unwrap().len() + + first_response.to_canonical_bytes().unwrap().len() + ); + let packed = block_on(serve_sync_request( + &store, + &cursor_key, + &SyncRequest::new( + account_id, + Vec::new(), + None, + 2, + first_response.to_canonical_bytes().unwrap().len(), + ) + .unwrap(), + )) + .unwrap(); + assert_eq!(packed.as_frame().unwrap().events().len(), 1); + assert!(packed.as_frame().unwrap().continuation().is_some()); + + let exhausted = krikos_identity::SyncCursor::issue( + &cursor_key, + account_id, + current.revision().heads().to_vec(), + 1, + krikos_identity::limits::MAX_SYNC_SESSION_BYTES - 1, + ) + .unwrap(); + assert!(matches!( + block_on(serve_sync_request( + &store, + &cursor_key, + &SyncRequest::new(account_id, Vec::new(), Some(exhausted), 1, 4 * 1024 * 1024,) + .unwrap(), + )), + Err(krikos_identity::IdentityError::LimitExceeded { + resource: "sync session bytes", + .. + }) + )); + let substituted_heads = krikos_identity::SyncCursor::issue( + &cursor_key, + account_id, + vec![typed_id::(0xfe)], + 0, + 0, + ) + .unwrap(); + assert_eq!( + block_on(serve_sync_request( + &store, + &cursor_key, + &SyncRequest::new(account_id, Vec::new(), Some(substituted_heads), 1, 1024,).unwrap(), + )), + Err(krikos_identity::IdentityError::InvalidRelationship { + resource: "account event-history source revision", + }) + ); + let foreign_cursor = krikos_identity::SyncCursor::issue( + &krikos_identity::CursorKey::new([0x45; 32]).unwrap(), + account_id, + current.revision().heads().to_vec(), + 1, + 0, + ) + .unwrap(); + assert_eq!( + block_on(serve_sync_request( + &store, + &cursor_key, + &SyncRequest::new(account_id, Vec::new(), Some(foreign_cursor), 1, 1024,).unwrap(), + )), + Err(krikos_identity::IdentityError::InvalidProof) + ); + + let third = authorized_add_controller( + current.state(), + &signer, + &SecretKey::from_bytes(&[10; 32]), + 3, + ); + block_on(store.commit_event(current.revision().clone(), third)).unwrap(); + let resumed = block_on(serve_sync_request( + &store, + &cursor_key, + &SyncRequest::new( + account_id, + Vec::new(), + Some(continuation), + 1, + 4 * 1024 * 1024, + ) + .unwrap(), + )) + .unwrap(); + let resumed_frame = resumed.as_frame().unwrap(); + assert_eq!(resumed_frame.events(), std::slice::from_ref(&second)); + assert!(resumed_frame.continuation().is_none()); +} + +#[test] +fn checkpoint_journal_is_revision_bound_idempotent_and_bounded() { + let genesis = genesis(); + let signer = SecretKey::from_bytes(&[7; 32]); + let account_id = genesis.account_id().unwrap(); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let event = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[10; 32]), + 10, + ); + let committed = block_on(store.commit_event(initial.revision().clone(), event)).unwrap(); + let checkpoint = verified_checkpoint( + committed.snapshot().state(), + &signer, + Timestamp::from_unix_millis(11), + ); + + let first = block_on( + store.commit_checkpoint(committed.snapshot().revision().clone(), checkpoint.clone()), + ) + .unwrap(); + assert_eq!(first.checkpoint_id(), checkpoint.checkpoint_id()); + assert_eq!(first.snapshot().checkpoints().len(), 1); + let replay = block_on( + store.commit_checkpoint(committed.snapshot().revision().clone(), checkpoint.clone()), + ) + .unwrap(); + assert_eq!(replay.snapshot().checkpoints().len(), 1); + + let lease_id = LeaseId::new([0x22; 16]).unwrap(); + let claimed = block_on( + store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(20), + Timestamp::from_unix_millis(30), + lease_id, + 3, + ) + .unwrap(), + ), + ) + .unwrap(); + let publish = claimed + .iter() + .find(|record| { + matches!( + record.effect(), + ProjectionEffect::PublishAccountEvent { .. } + ) + }) + .unwrap() + .clone(); + let operational_store = MemoryOperationalEffectStore::new(); + let journal = OperationalEffectJournal::new(operational_store.clone()); + assert_eq!( + journal + .begin(&publish, Timestamp::from_unix_millis(21)) + .unwrap() + .phase(), + OperationalEffectPhase::Claimed + ); + journal + .record_checkpoint_draft( + publish.id(), + checkpoint.checkpoint().body().clone(), + Timestamp::from_unix_millis(22), + ) + .unwrap(); + let authorized = journal + .record_checkpoint_authorized( + publish.id(), + &checkpoint, + committed.snapshot().state().provider_policy(), + Timestamp::from_unix_millis(23), + ) + .unwrap(); + assert_eq!( + authorized.phase(), + OperationalEffectPhase::CheckpointAuthorized + ); + assert_eq!( + operational_store + .metrics() + .unwrap() + .publication_shortfalls(), + 0 + ); + assert_eq!( + journal + .record_failure( + publish.id(), + 1, + EffectFailure::transient(9).unwrap(), + Timestamp::from_unix_millis(25), + ) + .unwrap() + .phase(), + OperationalEffectPhase::RetryScheduled + ); + let changed_failure = journal + .record_failure( + publish.id(), + 1, + EffectFailure::transient(10).unwrap(), + Timestamp::from_unix_millis(26), + ) + .unwrap(); + assert_eq!( + changed_failure.last_failure(), + Some(EffectFailure::transient(10).unwrap()) + ); + assert_eq!(changed_failure.revision(), 5); + + block_on(store.retry_effect( + account_id, + publish.id(), + lease_id, + Timestamp::from_unix_millis(30), + EffectFailure::transient(10).unwrap(), + )) + .unwrap(); + let retry_lease = LeaseId::new([0x23; 16]).unwrap(); + let retried = block_on( + store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(30), + Timestamp::from_unix_millis(40), + retry_lease, + 3, + ) + .unwrap(), + ), + ) + .unwrap() + .into_iter() + .find(|record| record.id() == publish.id()) + .unwrap(); + let resumed = journal + .begin(&retried, Timestamp::from_unix_millis(31)) + .unwrap(); + assert_eq!( + resumed.phase(), + OperationalEffectPhase::CheckpointAuthorized + ); + assert_eq!(resumed.checkpoint(), Some(checkpoint.checkpoint())); + assert_eq!(resumed.lease_id(), retry_lease); + assert_eq!(resumed.attempt_count(), 2); + assert_eq!(resumed.last_failure(), retried.last_failure()); + assert_eq!( + block_on(store.commit_checkpoint(initial.revision().clone(), checkpoint)), + Err(krikos_identity::IdentityError::StaleRevision) + ); + + let page = block_on(store.checkpoint_history(account_id, None, 1, 4 * 1024 * 1024)).unwrap(); + assert_eq!(page.records().len(), 1); + assert_eq!(page.records()[0].checkpoint_id(), first.checkpoint_id()); + assert!(page.next_cursor().is_none()); +} + +#[test] +fn local_only_checkpoint_effect_completes_without_synthetic_provider_stages() { + let genesis = genesis(); + let signer = SecretKey::from_bytes(&[7; 32]); + let account_id = genesis.account_id().unwrap(); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let event = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[0x61; 32]), + 61, + ); + let committed = block_on(store.commit_event(initial.revision().clone(), event)).unwrap(); + let checkpoint = verified_checkpoint( + committed.snapshot().state(), + &signer, + Timestamp::from_unix_millis(62), + ); + let lease_id = LeaseId::new([0x62; 16]).unwrap(); + let claimed = block_on( + store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(70), + Timestamp::from_unix_millis(80), + lease_id, + 4, + ) + .unwrap(), + ), + ) + .unwrap(); + let publish = claimed + .iter() + .find(|effect| { + matches!( + effect.effect(), + ProjectionEffect::PublishAccountEvent { .. } + ) + }) + .unwrap(); + let operational_store = MemoryOperationalEffectStore::new(); + let journal = OperationalEffectJournal::new(operational_store.clone()); + journal + .begin(publish, Timestamp::from_unix_millis(71)) + .unwrap(); + journal + .record_checkpoint_draft( + publish.id(), + checkpoint.checkpoint().body().clone(), + Timestamp::from_unix_millis(72), + ) + .unwrap(); + assert_eq!( + block_on(complete_ready_effect( + &store, + &journal, + publish, + Timestamp::from_unix_millis(73), + )), + Err(krikos_identity::IdentityError::InvalidRelationship { + resource: "operational completion prerequisite" + }) + ); + let before_authorization = block_on(store.load_account(account_id)).unwrap().unwrap(); + assert_eq!( + before_authorization + .outbox() + .iter() + .find(|effect| effect.id() == publish.id()) + .unwrap() + .status(), + EffectStatus::Claimed + ); + let authorized = journal + .record_checkpoint_authorized( + publish.id(), + &checkpoint, + committed.snapshot().state().provider_policy(), + Timestamp::from_unix_millis(74), + ) + .unwrap(); + assert_eq!( + authorized.phase(), + OperationalEffectPhase::CheckpointAuthorized + ); + assert!(authorized.provider_receipts().is_empty()); + block_on(complete_ready_effect( + &store, + &journal, + publish, + Timestamp::from_unix_millis(75), + )) + .unwrap(); + + let completed = journal.load(publish.id()).unwrap().unwrap(); + assert_eq!(completed.phase(), OperationalEffectPhase::Completed); + assert!(completed.provider_receipts().is_empty()); + assert!(completed.audit().iter().all(|audit| { + !matches!( + audit.phase(), + OperationalEffectPhase::Published + | OperationalEffectPhase::Replicated + | OperationalEffectPhase::Observed + ) + })); + let snapshot = block_on(store.load_account(account_id)).unwrap().unwrap(); + assert_eq!( + snapshot + .outbox() + .iter() + .find(|effect| effect.id() == publish.id()) + .unwrap() + .status(), + EffectStatus::Completed + ); + assert_eq!(operational_store.metrics().unwrap().completed(), 1); + assert_eq!( + operational_store + .metrics() + .unwrap() + .publication_shortfalls(), + 0 + ); +} + +#[test] +fn stale_sibling_cas_retains_both_valid_sequence_one_branches() { + let genesis = genesis(); + let signer = SecretKey::from_bytes(&[7; 32]); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let left = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[11; 32]), + 11, + ); + let right = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[12; 32]), + 12, + ); + + block_on(store.commit_event(initial.revision().clone(), left.clone())).unwrap(); + let fork = block_on(store.commit_event(initial.revision().clone(), right.clone())).unwrap(); + + assert_eq!(fork.outcome().disposition(), ApplyDisposition::ForkDetected); + assert_eq!(fork.snapshot().revision().heads().len(), 2); + assert!( + fork.snapshot() + .revision() + .heads() + .contains(&left.event_id().unwrap()) + ); + assert!( + fork.snapshot() + .revision() + .heads() + .contains(&right.event_id().unwrap()) + ); + assert_eq!(fork.snapshot().fork_evidence().len(), 1); + assert_eq!( + fork.snapshot().fork_evidence()[0].sequence(), + Sequence::new(1) + ); + assert_eq!(fork.snapshot().outbox().len(), 5); +} + +#[test] +fn evicted_lineage_conflict_is_authenticated_from_durable_sources() { + let genesis = genesis(); + let signer = SecretKey::from_bytes(&[7; 32]); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let initial_revision = initial.revision().clone(); + let mut projected_state = initial.state().clone(); + let mut durable_events = Vec::with_capacity(256); + for version in 1_u64..=256 { + let policy = + ProviderPolicy::local_only(ProviderPolicyVersion::new(version), Extensions::default()) + .unwrap(); + let event = authorized_operation( + &projected_state, + &signer, + AccountOperation::ChangeProviderPolicy(policy), + version, + ); + projected_state.validate_and_apply(&event).unwrap(); + durable_events.push(event); + } + let batch = block_on(store.commit_events(initial_revision.clone(), durable_events)).unwrap(); + assert_eq!(batch.outcomes().len(), 256); + let policy = + ProviderPolicy::local_only(ProviderPolicyVersion::new(257), Extensions::default()).unwrap(); + let event = authorized_operation( + batch.snapshot().state(), + &signer, + AccountOperation::ChangeProviderPolicy(policy), + 257, + ); + let snapshot = block_on(store.commit_event(batch.snapshot().revision().clone(), event)) + .unwrap() + .snapshot() + .clone(); + let [durable_tip] = snapshot.revision().heads() else { + panic!("linear durable history must have one tip"); + }; + let durable_tip = *durable_tip; + let alternate = authorized_add_controller( + &AccountState::from_genesis(snapshot.genesis()).unwrap(), + &signer, + &SecretKey::from_bytes(&[50; 32]), + 50, + ); + let alternate_id = alternate.event_id().unwrap(); + + let fork = block_on(store.commit_event(initial_revision, alternate)).unwrap(); + assert_eq!(fork.outcome().disposition(), ApplyDisposition::ForkDetected); + assert_eq!(fork.snapshot().revision().heads().len(), 2); + assert_eq!(fork.snapshot().events().len(), 258); + assert_eq!(fork.snapshot().fork_evidence().len(), 1); + let mut expected_heads = vec![durable_tip, alternate_id]; + expected_heads.sort_unstable(); + assert_eq!(fork.snapshot().revision().heads(), expected_heads); +} + +#[test] +fn effect_claim_retry_and_completion_are_bounded_and_idempotent() { + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let signer = SecretKey::from_bytes(&[7; 32]); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let event = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[20; 32]), + 20, + ); + block_on(store.commit_event(initial.revision().clone(), event)).unwrap(); + + let lease = LeaseId::new([1; 16]).unwrap(); + let claim = ClaimEffects::new( + Timestamp::from_unix_millis(100), + Timestamp::from_unix_millis(200), + lease, + 2, + ) + .unwrap(); + let first_claim = block_on(store.claim_effects(account_id, claim)).unwrap(); + assert_eq!(first_claim.len(), 2); + assert!( + first_claim.iter().all(|effect| { + effect.status() == EffectStatus::Claimed && effect.attempt_count() == 1 + }) + ); + + let repeated = block_on(store.claim_effects(account_id, claim)).unwrap(); + assert_eq!(repeated, first_claim); + + let completed_id = first_claim[0].id(); + block_on(store.complete_effect( + account_id, + completed_id, + lease, + Timestamp::from_unix_millis(150), + )) + .unwrap(); + block_on(store.complete_effect( + account_id, + completed_id, + lease, + Timestamp::from_unix_millis(150), + )) + .unwrap(); + + let retried_id = first_claim[1].id(); + let failure = EffectFailure::transient(7).unwrap(); + block_on(store.retry_effect( + account_id, + retried_id, + lease, + Timestamp::from_unix_millis(300), + failure, + )) + .unwrap(); + let before_retry = ClaimEffects::new( + Timestamp::from_unix_millis(299), + Timestamp::from_unix_millis(350), + LeaseId::new([2; 16]).unwrap(), + 2, + ) + .unwrap(); + assert!( + block_on(store.claim_effects(account_id, before_retry)) + .unwrap() + .iter() + .all(|record| record.id() != retried_id) + ); + let at_retry = ClaimEffects::new( + Timestamp::from_unix_millis(300), + Timestamp::from_unix_millis(400), + LeaseId::new([3; 16]).unwrap(), + 3, + ) + .unwrap(); + let claimed_again = block_on(store.claim_effects(account_id, at_retry)).unwrap(); + let retried = claimed_again + .iter() + .find(|record| record.id() == retried_id) + .expect("scheduled retry must become claimable at its exact timestamp"); + assert_eq!(retried.attempt_count(), 2); + assert_eq!(retried.last_failure(), Some(failure)); +} + +#[test] +fn sync_reconciliation_is_reorder_duplicate_tolerant_and_frame_atomic() { + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let signer = SecretKey::from_bytes(&[7; 32]); + let store = MemoryAccountStore::new(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let first = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[30; 32]), + 30, + ); + let mut after_first = initial.state().clone(); + after_first.validate_and_apply(&first).unwrap(); + let second = + authorized_add_controller(&after_first, &signer, &SecretKey::from_bytes(&[31; 32]), 31); + let frame = SyncFrame::new( + account_id, + vec![second.event_id().unwrap()], + vec![second.clone(), first.clone(), first], + None, + ) + .unwrap(); + let reconciled = block_on(reconcile_sync_frame( + &store, + initial.revision().clone(), + &frame, + )) + .unwrap(); + assert_eq!(reconciled.snapshot().events().len(), 2); + assert_eq!(reconciled.snapshot().outbox().len(), 6); + assert_eq!( + reconciled.snapshot().revision().heads(), + frame.source_heads() + ); + + let other_genesis = AccountGenesis::new( + [2; 32], + Timestamp::from_unix_millis(2), + initial.genesis().initial_policy().clone(), + initial.genesis().initial_controllers().to_vec(), + initial.genesis().initial_recovery_policy().clone(), + initial.genesis().initial_provider_policy().clone(), + Extensions::default(), + ) + .unwrap(); + let other_state = AccountState::from_genesis(&other_genesis).unwrap(); + let wrong_account_event = + authorized_add_controller(&other_state, &signer, &SecretKey::from_bytes(&[32; 32]), 32); + let next_valid = authorized_add_controller( + reconciled.snapshot().state(), + &signer, + &SecretKey::from_bytes(&[33; 32]), + 33, + ); + let invalid_frame = SyncFrame::new( + account_id, + vec![next_valid.event_id().unwrap()], + vec![next_valid, wrong_account_event], + None, + ) + .unwrap(); + assert_eq!( + block_on(reconcile_sync_frame( + &store, + reconciled.snapshot().revision().clone(), + &invalid_frame, + )), + Err(krikos_identity::IdentityError::AccountMismatch) + ); + let unchanged = block_on(store.load_account(account_id)).unwrap().unwrap(); + assert_eq!(unchanged.revision(), reconciled.snapshot().revision()); + assert_eq!(unchanged.events().len(), 2); + assert_eq!(unchanged.outbox().len(), 6); +} + +#[cfg(feature = "fs-store")] +#[test] +fn redb_reopen_preserves_atomic_event_and_outbox_and_rejects_truncation() { + use krikos_identity::RedbAccountStore; + use redb::{Database, TableDefinition}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("identity.redb"); + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let signer = SecretKey::from_bytes(&[7; 32]); + let frozen_revision; + { + let store = RedbAccountStore::open(&path).unwrap(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let event = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[40; 32]), + 40, + ); + let committed = block_on(store.commit_event(initial.revision().clone(), event)).unwrap(); + let second = authorized_add_controller( + committed.snapshot().state(), + &signer, + &SecretKey::from_bytes(&[41; 32]), + 41, + ); + let committed = + block_on(store.commit_event(committed.snapshot().revision().clone(), second)).unwrap(); + frozen_revision = committed.snapshot().revision().clone(); + assert_eq!(committed.snapshot().events().len(), 2); + assert_eq!(committed.snapshot().outbox().len(), 6); + let checkpoint = verified_checkpoint( + committed.snapshot().state(), + &signer, + Timestamp::from_unix_millis(42), + ); + block_on(store.commit_checkpoint(committed.snapshot().revision().clone(), checkpoint)) + .unwrap(); + } + { + let reopened = RedbAccountStore::open(&path).unwrap(); + let snapshot = block_on(reopened.load_account(account_id)) + .unwrap() + .unwrap(); + assert_eq!(snapshot.events().len(), 2); + assert_eq!(snapshot.outbox().len(), 6); + assert_eq!(snapshot.checkpoint_count(), 1); + assert_eq!(snapshot.checkpoints().len(), 1); + let page = + block_on(reopened.checkpoint_history(account_id, None, 1, 4 * 1024 * 1024)).unwrap(); + assert_eq!(page.records().len(), 1); + let event_page = + block_on(reopened.event_history(frozen_revision.clone(), None, 1, 4 * 1024 * 1024)) + .unwrap(); + assert_eq!(event_page.source_revision(), &frozen_revision); + assert_eq!(event_page.records().len(), 1); + let cursor = event_page.next_cursor().unwrap().clone(); + drop(event_page); + drop(reopened); + + let reopened = RedbAccountStore::open(&path).unwrap(); + let resumed = block_on(reopened.event_history( + frozen_revision.clone(), + Some(cursor), + 1, + 4 * 1024 * 1024, + )) + .unwrap(); + assert_eq!(resumed.records().len(), 1); + assert!(resumed.next_cursor().is_none()); + } + { + const TABLE: TableDefinition<&[u8], &[u8]> = + TableDefinition::new("krikos-identity-accounts-v1"); + let database = Database::create(&path).unwrap(); + let write = database.begin_write().unwrap(); + { + let mut table = write.open_table(TABLE).unwrap(); + let key = account_id.to_canonical_bytes().unwrap(); + table.insert(key.as_slice(), &[0xff, 0x00][..]).unwrap(); + } + write.commit().unwrap(); + } + assert!(matches!( + RedbAccountStore::open(&path), + Err(krikos_identity::IdentityError::StorageCorruption) + )); +} + +#[cfg(feature = "fs-store")] +#[test] +fn redb_terminal_effect_failure_remains_auditable_after_reopen() { + use krikos_identity::{IdentityError, RedbAccountStore}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("identity.redb"); + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let signer = SecretKey::from_bytes(&[7; 32]); + let lease_id = LeaseId::new([9; 16]).unwrap(); + let failure = EffectFailure::permanent(42).unwrap(); + let effect_id; + + { + let store = RedbAccountStore::open(&path).unwrap(); + let initial = block_on(store.create_account(genesis)).unwrap(); + let event = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[41; 32]), + 41, + ); + block_on(store.commit_event(initial.revision().clone(), event)).unwrap(); + let claim = ClaimEffects::new( + Timestamp::from_unix_millis(100), + Timestamp::from_unix_millis(200), + lease_id, + 1, + ) + .unwrap(); + effect_id = block_on(store.claim_effects(account_id, claim)).unwrap()[0].id(); + assert_eq!( + block_on(store.retry_effect( + account_id, + effect_id, + lease_id, + Timestamp::from_unix_millis(300), + failure, + )), + Err(IdentityError::RetryExhausted) + ); + } + + let reopened = RedbAccountStore::open(&path).unwrap(); + let snapshot = block_on(reopened.load_account(account_id)) + .unwrap() + .unwrap(); + let exhausted = snapshot + .outbox() + .iter() + .find(|record| record.id() == effect_id) + .unwrap(); + assert_eq!(exhausted.status(), EffectStatus::Pending); + assert_eq!(exhausted.last_failure(), Some(failure)); + assert!(exhausted.retry_exhausted()); +} + +#[cfg(feature = "provider-store")] +#[test] +fn redb_operational_substeps_reopen_by_stable_task6_effect_id() { + use krikos_identity::RedbOperationalEffectStore; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("operations.redb"); + let genesis = genesis(); + let account_id = genesis.account_id().unwrap(); + let signer = SecretKey::from_bytes(&[7; 32]); + let account_store = MemoryAccountStore::new(); + let initial = block_on(account_store.create_account(genesis)).unwrap(); + let event = authorized_add_controller( + initial.state(), + &signer, + &SecretKey::from_bytes(&[51; 32]), + 51, + ); + block_on(account_store.commit_event(initial.revision().clone(), event)).unwrap(); + let lease_id = LeaseId::new([0x33; 16]).unwrap(); + let claimed = block_on( + account_store.claim_effects( + account_id, + ClaimEffects::new( + Timestamp::from_unix_millis(100), + Timestamp::from_unix_millis(200), + lease_id, + 3, + ) + .unwrap(), + ), + ) + .unwrap(); + let notification = claimed + .iter() + .find(|record| { + matches!( + record.effect(), + ProjectionEffect::NotifyAccountChanged { .. } + ) + }) + .unwrap(); + let effect_id = notification.id(); + + { + let operation_store = RedbOperationalEffectStore::open(&path).unwrap(); + let journal = OperationalEffectJournal::new(operation_store); + journal + .begin(notification, Timestamp::from_unix_millis(110)) + .unwrap(); + journal + .record_peers_notified(effect_id, Timestamp::from_unix_millis(111)) + .unwrap(); + block_on(account_store.complete_effect( + account_id, + effect_id, + lease_id, + Timestamp::from_unix_millis(112), + )) + .unwrap(); + journal + .record_completed(effect_id, Timestamp::from_unix_millis(113)) + .unwrap(); + } + + let reopened = RedbOperationalEffectStore::open(&path).unwrap(); + let journal = OperationalEffectJournal::new(reopened.clone()); + assert_eq!( + journal.load(effect_id).unwrap().unwrap().phase(), + OperationalEffectPhase::Completed + ); + assert_eq!(reopened.metrics().unwrap().completed(), 1); +} diff --git a/protocols/krikos-identity/tests/sync_contracts.rs b/protocols/krikos-identity/tests/sync_contracts.rs new file mode 100644 index 00000000000..5c5486fefc7 --- /dev/null +++ b/protocols/krikos-identity/tests/sync_contracts.rs @@ -0,0 +1,129 @@ +use krikos_identity::{ + AccountId, CanonicalWire, CursorKey, Digest, EventId, HashAlgorithm, IdentityError, + ProtocolVersion, SyncCursor, SyncFrame, SyncRequest, SyncResponse, SyncSessionBudget, + limits::{MAX_EVENTS_PER_SYNC_BATCH, MAX_SYNC_FRAME_BYTES, MAX_SYNC_SESSION_BYTES}, + transport::{ + CHECKPOINT_ALPN, PAIRING_ALPN, PROPOSAL_ALPN, RECOVERY_ALPN, SYNC_ALPN, + TRANSPARENCY_GOSSIP_ALPN, + }, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +#[test] +fn sync_response_uses_closed_codepoints_and_rejects_ordinal_wire_bytes() { + let account_id = typed_id(0x41); + let frame = SyncFrame::new(account_id, Vec::new(), Vec::new(), None).unwrap(); + let frame_response = SyncResponse::frame(frame.clone()); + assert_eq!(frame_response.code(), SyncResponse::FRAME_CODE); + assert_eq!( + SyncResponse::from_canonical_bytes(&frame_response.to_canonical_bytes().unwrap()).unwrap(), + frame_response + ); + + let complete = SyncResponse::complete(account_id, Vec::new()).unwrap(); + let complete_bytes = complete.to_canonical_bytes().unwrap(); + assert_eq!(complete.code(), SyncResponse::COMPLETE_CODE); + assert_eq!(&complete_bytes[..3], &[1, 2, 0]); + assert_eq!( + SyncResponse::from_canonical_bytes(&complete_bytes).unwrap(), + complete + ); + + let unknown = postcard::to_stdvec(&( + ProtocolVersion::V1, + 99_u16, + Option::::None, + Option::::None, + Option::>::None, + )) + .unwrap(); + assert!(matches!( + SyncResponse::from_canonical_bytes(&unknown), + Err(IdentityError::UnsupportedCodepoint { + registry: "sync response", + code: 99, + }) + )); + + let old_frame_ordinal = postcard::to_stdvec(&(ProtocolVersion::V1, 0_u32, &frame)).unwrap(); + assert!(SyncResponse::from_canonical_bytes(&old_frame_ordinal).is_err()); + let old_complete_ordinal = postcard::to_stdvec(&( + ProtocolVersion::V1, + 1_u32, + account_id, + Vec::::new(), + )) + .unwrap(); + assert!(SyncResponse::from_canonical_bytes(&old_complete_ordinal).is_err()); +} + +#[test] +fn sync_request_cursor_and_frame_are_canonical_and_bounded() { + let account_id = typed_id(1); + let head_a = typed_id::(2); + let head_b = typed_id::(3); + let key = CursorKey::new([7; 32]).unwrap(); + let cursor = SyncCursor::issue(&key, account_id, vec![head_b, head_a], 17, 1_024).unwrap(); + cursor.verify(&key).unwrap(); + assert_eq!( + cursor.verify(&CursorKey::new([8; 32]).unwrap()), + Err(IdentityError::InvalidProof) + ); + assert_eq!(cursor.source_heads(), &[head_a, head_b]); + + let request = SyncRequest::new( + account_id, + vec![head_b, head_a], + Some(cursor.clone()), + MAX_EVENTS_PER_SYNC_BATCH, + MAX_SYNC_FRAME_BYTES, + ) + .unwrap(); + assert_eq!(request.known_heads(), &[head_a, head_b]); + let encoded = request.to_canonical_bytes().unwrap(); + assert_eq!( + SyncRequest::from_canonical_bytes(&encoded).unwrap(), + request + ); + + let frame = SyncFrame::new(account_id, vec![head_b, head_a], Vec::new(), Some(cursor)).unwrap(); + assert_eq!(frame.source_heads(), &[head_a, head_b]); + let frame_bytes = frame.to_canonical_bytes().unwrap(); + assert!(frame_bytes.len() <= MAX_SYNC_FRAME_BYTES); + assert_eq!( + SyncFrame::from_canonical_bytes(&frame_bytes).unwrap(), + frame + ); + assert!(matches!( + SyncFrame::from_canonical_bytes(&vec![0; MAX_SYNC_FRAME_BYTES + 1]), + Err(IdentityError::LimitExceeded { .. }) + )); +} + +#[test] +fn session_budget_is_exact_and_cannot_overflow() { + let mut budget = SyncSessionBudget::new(); + budget.charge_bytes(MAX_SYNC_SESSION_BYTES).unwrap(); + assert_eq!(budget.remaining_bytes(), 0); + assert!(matches!( + budget.charge_bytes(1), + Err(IdentityError::LimitExceeded { .. }) + )); +} + +#[test] +fn v1_alpns_are_frozen_exactly() { + assert_eq!(PAIRING_ALPN, b"krikos-identity/pairing/1"); + assert_eq!(SYNC_ALPN, b"krikos-identity/sync/1"); + assert_eq!(PROPOSAL_ALPN, b"krikos-identity/proposal/1"); + assert_eq!(CHECKPOINT_ALPN, b"krikos-identity/checkpoint/1"); + assert_eq!( + TRANSPARENCY_GOSSIP_ALPN, + b"krikos-identity/transparency-gossip/1" + ); + assert_eq!(RECOVERY_ALPN, b"krikos-identity/recovery/1"); +} diff --git a/protocols/krikos-identity/tests/task2_golden_vectors.rs b/protocols/krikos-identity/tests/task2_golden_vectors.rs new file mode 100644 index 00000000000..ad6f462c8d2 --- /dev/null +++ b/protocols/krikos-identity/tests/task2_golden_vectors.rs @@ -0,0 +1,821 @@ +use krikos_identity::*; + +const SIGNING_KEY_1: [u8; 32] = [ + 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a, + 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a, +]; +const SIGNING_KEY_2: [u8; 32] = [ + 0x3d, 0x40, 0x17, 0xc3, 0xe8, 0x43, 0x89, 0x5a, 0x92, 0xb7, 0x0a, 0xa7, 0x4d, 0x1b, 0x7e, 0xbc, + 0x9c, 0x98, 0x2c, 0xcf, 0x2e, 0xc4, 0x96, 0x8c, 0xc0, 0xcd, 0x55, 0xf1, 0x2a, 0xf4, 0x66, 0x0c, +]; +const SIGNING_KEY_3: [u8; 32] = [ + 0xfc, 0x51, 0xcd, 0x8e, 0x62, 0x18, 0xa1, 0xa3, 0x8d, 0xa4, 0x7e, 0xd0, 0x02, 0x30, 0xf0, 0x58, + 0x08, 0x16, 0xed, 0x13, 0xba, 0x33, 0x03, 0xac, 0x5d, 0xeb, 0x91, 0x15, 0x48, 0x90, 0x80, 0x25, +]; + +fn digest(fill: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [fill; 32]) +} + +fn typed_id(fill: u8) -> T { + T::from_canonical_bytes(&digest(fill).to_canonical_bytes().unwrap()).unwrap() +} + +fn controller() -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(SIGNING_KEY_1).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn device_descriptor(signing: [u8; 32], endpoint: [u8; 32]) -> DeviceDescriptor { + let mut agreement = [0_u8; 32]; + agreement[0] = 9; + DeviceDescriptor::new( + SigningPublicKey::ed25519(signing).unwrap(), + AgreementPublicKey::x25519(agreement).unwrap(), + EndpointPublicKey::new(SigningPublicKey::ed25519(endpoint).unwrap()), + Extensions::default(), + ) + .unwrap() +} + +fn device_authorization(signing: [u8; 32], endpoint: [u8; 32]) -> DeviceAuthorization { + let descriptor = device_descriptor(signing, endpoint); + DeviceAuthorization::new( + descriptor.id().unwrap(), + descriptor, + DeviceClass::GeneralPurpose, + None, + Vec::new(), + Epoch::new(2), + Extensions::default(), + ) + .unwrap() +} + +fn policy_rule(operation: OperationKind) -> PolicyRule { + PolicyRule::new( + operation, + RequiredWeight::new(1).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + None, + Extensions::default(), + ) + .unwrap() +} + +fn control_policy() -> ControlPolicy { + ControlPolicy::new( + vec![policy_rule(OperationKind::ChangeControlPolicy)], + Extensions::default(), + ) + .unwrap() +} + +fn recovery_policy(version: u64) -> RecoveryPolicy { + RecoveryPolicy::new( + RecoveryPolicyVersion::new(version), + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(100), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap() +} + +fn recovery_proposal() -> RecoveryProposal { + let plan = RecoveryAuthorityPlan::try_new( + ProtocolVersion::V1, + typed_id::(1), + typed_id::(2), + typed_id::(3), + typed_id::(4), + RecoveryPolicyVersion::new(4), + [5; 32], + vec![controller()], + control_policy(), + recovery_policy(5), + Vec::new(), + Timestamp::from_unix_millis(2_000), + Extensions::default(), + ) + .unwrap(); + RecoveryProposal::try_new(ProtocolVersion::V1, plan, Extensions::default()).unwrap() +} + +fn provider_receipt(account_id: AccountId, proposal_id: ProposalId) -> InclusionReceipt { + let provider_id = typed_id::(31); + let log_id = typed_id::(32); + let entry = ProviderLogEntryBody::new( + provider_id, + log_id, + account_id, + ProviderLogSubject::EventIntent(proposal_id), + Timestamp::from_unix_millis(300), + Extensions::default(), + ) + .unwrap(); + let head = ProviderHeadBody::new( + provider_id, + log_id, + ProviderKeyVersion::GENESIS, + 1, + digest(33), + Timestamp::from_unix_millis(301), + Extensions::default(), + ) + .unwrap(); + InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(head, ProtocolSignature::ed25519([34; 64])), + ) + .unwrap() +} + +fn recovery_operations() -> ( + BeginRecovery, + VetoRecovery, + CancelRecovery, + FinalizeRecovery, +) { + let proposal = recovery_proposal(); + let recovery_id = proposal.recovery_id().unwrap(); + let evidence = RecoveryThresholdEvidence::controller_policy( + proposal.plan().recovery_policy_id(), + proposal.plan().recovery_policy_version(), + ); + let begin = BeginRecovery::try_new( + ProtocolVersion::V1, + proposal.clone(), + evidence.clone(), + Extensions::default(), + ) + .unwrap(); + let freshness = FreshnessEvidence::local_known(proposal.plan().prior_checkpoint_id()); + let veto = VetoRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + typed_id::(35), + freshness.clone(), + Extensions::default(), + ) + .unwrap(); + let cancel = CancelRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + evidence, + freshness, + Extensions::default(), + ) + .unwrap(); + let begin_proposal_id = typed_id::(36); + let receipts = ProviderReceipts::new(vec![provider_receipt( + proposal.plan().account_id(), + begin_proposal_id, + )]) + .unwrap(); + let anchor = RecoveryDelayAnchor::try_new( + ProtocolVersion::V1, + proposal.plan().account_id(), + recovery_id, + begin_proposal_id, + typed_id::(37), + ProviderQuorum::new(1).unwrap(), + receipts, + Extensions::default(), + ) + .unwrap(); + let finalize = FinalizeRecovery::try_new( + ProtocolVersion::V1, + recovery_id, + anchor, + Timestamp::from_unix_millis(400), + Extensions::default(), + ) + .unwrap(); + (begin, veto, cancel, finalize) +} + +fn migration_parts() -> (BeginCryptoMigration, CryptoMigrationId) { + let binding = ControllerKeyBinding::try_new( + typed_id::(41), + typed_id::(42), + AlgorithmPublicKey::new(2, vec![43]).unwrap(), + Extensions::default(), + ) + .unwrap(); + let migration = CryptoMigrationBody::try_new( + ProtocolVersion::V1, + typed_id::(1), + typed_id::(44), + CryptoSuiteDescriptor::try_new( + ProtocolVersion::V1, + 2, + 1, + 2, + 1, + 1, + 1, + Extensions::default(), + ) + .unwrap(), + vec![binding], + None, + [45; 32], + Extensions::default(), + ) + .unwrap(); + let migration_id = migration.crypto_migration_id().unwrap(); + let proof = ControllerKeyBindingProof::try_new( + migration_id, + typed_id::(41), + AlgorithmSignature::new(1, vec![46; 64]).unwrap(), + AlgorithmSignature::new(2, vec![47]).unwrap(), + ) + .unwrap(); + let begin = BeginCryptoMigration::try_new( + ProtocolVersion::V1, + migration, + ControllerKeyBindingProofSet::try_new(vec![proof]).unwrap(), + Extensions::default(), + ) + .unwrap(); + (begin, migration_id) +} + +fn account_operations() -> Vec { + let old_authorization = device_authorization(SIGNING_KEY_1, SIGNING_KEY_2); + let new_authorization = device_authorization(SIGNING_KEY_2, SIGNING_KEY_3); + let old_device_id = old_authorization.device_id(); + let (begin_recovery, veto_recovery, cancel_recovery, finalize_recovery) = recovery_operations(); + let fork = ForkDescriptor::try_new( + ProtocolVersion::V1, + typed_id::(1), + ForkCommonAncestor::Event(typed_id::(48)), + vec![typed_id::(49), typed_id::(50)], + Extensions::default(), + ) + .unwrap(); + let resolution = ResolveFork::try_new( + ProtocolVersion::V1, + fork, + typed_id::(49), + vec![typed_id::(51)], + vec![typed_id::(52)], + Extensions::default(), + ) + .unwrap(); + let (begin_migration, migration_id) = migration_parts(); + vec![ + AccountOperation::AuthorizeDevice(old_authorization.clone()), + AccountOperation::UpdateDeviceAuthorization( + DeviceAuthorizationUpdate::new( + old_device_id, + DeviceClass::HardwareBacked, + Vec::new(), + Epoch::new(3), + Extensions::default(), + ) + .unwrap(), + ), + AccountOperation::UpdateDeviceMetadata( + DeviceMetadataUpdate::new(old_device_id, None, Extensions::default()).unwrap(), + ), + AccountOperation::SuspendDevice( + SuspendDevice::new(old_device_id, Extensions::default()).unwrap(), + ), + AccountOperation::ReinstateDevice( + ReinstateDevice::new(old_device_id, Extensions::default()).unwrap(), + ), + AccountOperation::RevokeDevice( + RevokeDevice::new( + old_device_id, + Some(RevocationReasonCode::new(7).unwrap()), + Extensions::default(), + ) + .unwrap(), + ), + AccountOperation::RotateDeviceKeys( + RotateDeviceKeys::new(old_device_id, new_authorization, Extensions::default()).unwrap(), + ), + AccountOperation::AddController(controller()), + AccountOperation::RemoveController(typed_id::(53)), + AccountOperation::ChangeControlPolicy(control_policy()), + AccountOperation::ChangeRecoveryPolicy(recovery_policy(6)), + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(7), Extensions::default()) + .unwrap(), + ), + AccountOperation::BeginRecovery(begin_recovery), + AccountOperation::VetoRecovery(veto_recovery), + AccountOperation::CancelRecovery(cancel_recovery), + AccountOperation::FinalizeRecovery(finalize_recovery), + AccountOperation::ResolveFork(resolution), + AccountOperation::BeginCryptoMigration(begin_migration), + AccountOperation::ActivateCryptoMigration( + ActivateCryptoMigration::try_new( + ProtocolVersion::V1, + migration_id, + typed_id::(54), + Extensions::default(), + ) + .unwrap(), + ), + AccountOperation::RetireCryptoSuite( + RetireCryptoSuite::try_new( + ProtocolVersion::V1, + migration_id, + RetireCryptoSuiteMode::AbortCandidate, + typed_id::(55), + None, + Extensions::default(), + ) + .unwrap(), + ), + AccountOperation::UpgradeProtocol( + ProtocolUpgrade::try_new( + ProtocolVersion::V1, + ProtocolMajor::new(1).unwrap(), + ProtocolMajor::new(2).unwrap(), + digest(56), + UpgradeCompatibility::OldClientsReadOnly, + None, + Extensions::default(), + ) + .unwrap(), + ), + AccountOperation::RetireAccount( + RetireAccount::try_new( + ProtocolVersion::V1, + None, + Some(RevocationReasonCode::new(8).unwrap()), + Extensions::default(), + ) + .unwrap(), + ), + ] +} + +fn keyed_signature(fill: u8) -> KeyedSignature { + KeyedSignature::new( + typed_id::(60), + typed_id::(61), + AlgorithmSignature::new(1, vec![fill; 64]).unwrap(), + ) +} + +fn event_body() -> EventBody { + EventBody::new( + typed_id::(1), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id::(62)), + AccountOperation::ChangeProviderPolicy( + ProviderPolicy::local_only(ProviderPolicyVersion::new(1), Extensions::default()) + .unwrap(), + ), + Timestamp::from_unix_millis(63), + [64; 16], + Extensions::default(), + ) + .unwrap() +} + +fn authorized_event() -> AuthorizedEvent { + let body = event_body(); + let proposal_id = body.proposal_id().unwrap(); + let checkpoint_id = typed_id::(65); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + typed_id::(66), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + let approval = SignedControllerApproval::new( + ControllerApprovalBody::event( + typed_id::(67), + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(), + vec![keyed_signature(68)], + ) + .unwrap(); + AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn checkpoint_body() -> CheckpointBody { + CheckpointBody::new( + typed_id::(1), + Epoch::new(2), + Sequence::new(3), + typed_id::(4), + digest(5), + digest(6), + digest(7), + typed_id::(8), + typed_id::(9), + typed_id::(10), + typed_id::(11), + AccountLifecycle::Active, + Timestamp::from_unix_millis(12), + Extensions::default(), + ) + .unwrap() +} + +fn transition_authorized_event() -> AuthorizedEvent { + let body = EventBody::new( + typed_id::(1), + Sequence::new(1), + Epoch::new(1), + EventPredecessors::genesis(typed_id::(86)), + AccountOperation::RetireAccount( + RetireAccount::try_new( + ProtocolVersion::V1, + None, + Some(RevocationReasonCode::new(9).unwrap()), + Extensions::default(), + ) + .unwrap(), + ), + Timestamp::from_unix_millis(87), + [88; 16], + Extensions::default(), + ) + .unwrap(); + let proposal_id = body.proposal_id().unwrap(); + let checkpoint_id = typed_id::(89); + let evidence = AdmissionEvidence::new( + proposal_id, + checkpoint_id, + typed_id::(90), + FreshnessEvidence::local_known(checkpoint_id), + DelayEvidence::none(), + Extensions::default(), + ) + .unwrap(); + let event_id = evidence.event_id_for_body(&body).unwrap(); + let approval = SignedControllerApproval::new( + ControllerApprovalBody::event( + typed_id::(91), + event_id, + evidence.admission_evidence_id().unwrap(), + Extensions::default(), + ) + .unwrap(), + vec![keyed_signature(92)], + ) + .unwrap(); + AuthorizedEvent::new( + body, + evidence, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap() +} + +fn retired_checkpoint_body(event_head: EventId) -> CheckpointBody { + CheckpointBody::new( + typed_id::(1), + Epoch::new(2), + Sequence::new(3), + event_head, + digest(5), + digest(6), + digest(7), + typed_id::(8), + typed_id::(9), + typed_id::(10), + typed_id::(11), + AccountLifecycle::Retired, + Timestamp::from_unix_millis(12), + Extensions::default(), + ) + .unwrap() +} + +fn guardian_evidence() -> ( + GuardianApprovalBody, + SignedGuardianApproval, + GuardianApprovalSet, + RecoveryThresholdEvidence, +) { + let proposal = recovery_proposal(); + let recovery_id = proposal.recovery_id().unwrap(); + let grant = GuardianGrant::try_new( + ProtocolVersion::V1, + proposal.plan().account_id(), + proposal.plan().recovery_policy_id(), + typed_id::(70), + SigningPublicKey::ed25519(SIGNING_KEY_2).unwrap(), + ControllerWeight::new(1).unwrap(), + Epoch::GENESIS, + Some(Timestamp::from_unix_millis(1_000)), + Extensions::default(), + ) + .unwrap(); + let opening = GuardianGrantOpening::try_new( + ProtocolVersion::V1, + grant, + BlindingSecret::try_new([71; 32]).unwrap(), + GuardianSetRoot::new(digest(72)).unwrap(), + 0, + Vec::new(), + Extensions::default(), + ) + .unwrap(); + let body = GuardianApprovalBody::try_new( + ProtocolVersion::V1, + proposal.plan().account_id(), + recovery_id, + GuardianApprovalDecision::Begin, + opening.guardian_grant_id(), + Epoch::GENESIS, + Timestamp::from_unix_millis(500), + Extensions::default(), + ) + .unwrap(); + let signed = SignedGuardianApproval::try_new( + body.clone(), + opening, + ProtocolSignature::ed25519([73; 64]), + ) + .unwrap(); + let approvals = GuardianApprovalSet::try_new(vec![signed.clone()]).unwrap(); + let evidence = RecoveryThresholdEvidence::guardian_approvals( + proposal.plan().recovery_policy_id(), + proposal.plan().recovery_policy_version(), + approvals.clone(), + ) + .unwrap(); + (body, signed, approvals, evidence) +} + +fn check_vector(name: &str, value: &T, expected_hex: &str) +where + T: CanonicalWire + std::fmt::Debug + PartialEq, +{ + let encoded = value.to_canonical_bytes().unwrap(); + let actual_hex = hex::encode(&encoded); + assert_eq!(actual_hex, expected_hex, "{name} canonical bytes changed"); + assert_eq!( + T::from_canonical_bytes(&encoded).unwrap(), + *value, + "{name} canonical decode changed" + ); +} + +const ACCOUNT_OPERATION_HEX: [&str; 22] = [ + "010101da52d8e74be6cd6ae1e32b6a67d7087f4f06efbc3558e30f81bac9138337c4a30101d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a010900000000000000000000000000000000000000000000000000000000000000013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c000100000200", + "020101da52d8e74be6cd6ae1e32b6a67d7087f4f06efbc3558e30f81bac9138337c4a302000300", + "030101da52d8e74be6cd6ae1e32b6a67d7087f4f06efbc3558e30f81bac9138337c4a30000", + "040101da52d8e74be6cd6ae1e32b6a67d7087f4f06efbc3558e30f81bac9138337c4a300", + "050101da52d8e74be6cd6ae1e32b6a67d7087f4f06efbc3558e30f81bac9138337c4a300", + "060101da52d8e74be6cd6ae1e32b6a67d7087f4f06efbc3558e30f81bac9138337c4a3010700", + "070101da52d8e74be6cd6ae1e32b6a67d7087f4f06efbc3558e30f81bac9138337c4a301017e5dc5cf5bb6d50a38d7bd5cca8e6e8d6bf653c38ed04155fb8ce4aa45988ffb01013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c01090000000000000000000000000000000000000000000000000000000000000001fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb91154890802500010000020000", + "080101d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a0101010000", + "09013535353535353535353535353535353535353535353535353535353535353535", + "0a01010a01010000010000000100", + "0b01060101010000010064e80700", + "0c0107010000", + "0d010001f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101010101010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202010303030303030303030303030303030303030303030303030303030303030303010404040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505010101d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a010101000001010a0101000001000000010001050101010000010064e8070000d00f0000010104040404040404040404040404040404040404040404040404040404040404040400", + "0e0101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0123232323232323232323232323232323232323232323232323232323232323230101020202020202020202020202020202020202020202020202020202020202020200", + "0f0101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc01010404040404040404040404040404040404040404040404040404040404040404040101020202020202020202020202020202020202020202020202020202020202020200", + "100101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101010101010101010101010101010101010101010101010101010101010101010101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc01242424242424242424242424242424242424242424242424242424242424242401252525252525252525252525252525252525252525252525252525252525252501ac020101011f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f01202020202020202020202020202020202020202020202020202020202020202001010101010101010101010101010101010101010101010101010101010101010102012424242424242424242424242424242424242424242424242424242424242424ac0200000001011f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f0120202020202020202020202020202020202020202020202020202020202020200001012121212121212121212121212121212121212121212121212121212121212121ad0200012222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222200900300", + "11010181e98d3929ad1faa900488c5ea00185c2b6098505dd8b5c805851c1e0e1ddfcf01010101010101010101010101010101010101010101010101010101010101010101020130303030303030303030303030303030303030303030303030303030303030300201313131313131313131313131313131313131313131313131313131313131313101323232323232323232323232323232323232323232323232323232323232323200013131313131313131313131313131313131313131313131313131313131313131010133333333333333333333333333333333333333333333333333333333333333330101343434343434343434343434343434343434343434343434343434343434343400", + "120101010101010101010101010101010101010101010101010101010101010101010101012c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c010201020101010001012929292929292929292929292929292929292929292929292929292929292929012a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a02012b00002d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d000101811a0ad367661b6f148e53a680a4bebb585d2e99210ec7f7a742263bd5cb1d6001292929292929292929292929292929292929292929292929292929292929292901402e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e02012f00", + "130101811a0ad367661b6f148e53a680a4bebb585d2e99210ec7f7a742263bd5cb1d6001363636363636363636363636363636363636363636363636363636363636363600", + "140101811a0ad367661b6f148e53a680a4bebb585d2e99210ec7f7a742263bd5cb1d60010137373737373737373737373737373737373737373737373737373737373737370000", + "15010102013838383838383838383838383838383838383838383838383838383838383838010000", + "160100010800", +]; + +#[test] +fn account_operation_vectors_cover_every_v1_code() { + let operations = account_operations(); + assert_eq!(operations.len(), 22); + for (index, (operation, expected_hex)) in + operations.iter().zip(ACCOUNT_OPERATION_HEX).enumerate() + { + let expected_code = u16::try_from(index + 1).unwrap(); + assert_eq!(operation.kind().code(), expected_code); + check_vector( + &format!("account_operation_{expected_code}"), + operation, + expected_hex, + ); + } +} + +#[test] +fn event_and_controller_approval_vectors_are_frozen() { + let proposal_id = typed_id::(80); + let intent_body = EventIntentApprovalBody::new( + typed_id::(81), + proposal_id, + Extensions::default(), + ) + .unwrap(); + let signed_intent = + SignedEventIntentApproval::new(intent_body.clone(), vec![keyed_signature(82)]).unwrap(); + let authorized = authorized_event(); + let approval = &authorized.approvals().as_slice()[0]; + + check_vector( + "event_intent_approval_body", + &intent_body, + "0101515151515151515151515151515151515151515151515151515151515151515101505050505050505050505050505050505050505050505050505050505050505000", + ); + check_vector( + "signed_event_intent_approval", + &signed_intent, + "010151515151515151515151515151515151515151515151515151515151515151510150505050505050505050505050505050505050505050505050505050505050500001013c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c013d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d014052525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252", + ); + check_vector( + "controller_approval_body", + approval.body(), + "010143434343434343434343434343434343434343434343434343434343434343430101fe5309a3fe9d29f354a0d40622c6c09ea45122b9fcce6531ef6fded48935cf3301e02065abe46435c7aebf6928ca7aa94321b0e63dea3e1f65180b9c72e433059e00", + ); + check_vector( + "signed_controller_approval", + approval, + "010143434343434343434343434343434343434343434343434343434343434343430101fe5309a3fe9d29f354a0d40622c6c09ea45122b9fcce6531ef6fded48935cf3301e02065abe46435c7aebf6928ca7aa94321b0e63dea3e1f65180b9c72e433059e0001013c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c013d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d014044444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444", + ); + check_vector( + "authorized_event", + &authorized, + "01010101010101010101010101010101010101010101010101010101010101010101010101013e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e0c01010100003f40404040404040404040404040404040000101f0d88014b200765346052222fe45aba219e8fddbc86692aac618440c7417c54d01414141414141414141414141414141414141414141414141414141414141414101424242424242424242424242424242424242424242424242424242424242424201014141414141414141414141414141414141414141414141414141414141414141000001010143434343434343434343434343434343434343434343434343434343434343430101fe5309a3fe9d29f354a0d40622c6c09ea45122b9fcce6531ef6fded48935cf3301e02065abe46435c7aebf6928ca7aa94321b0e63dea3e1f65180b9c72e433059e0001013c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c013d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d014044444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444", + ); + assert_eq!( + intent_body.event_intent_approval_id().unwrap().to_string(), + "b3:3a9db552289e4f1fa5d19ebb2bbc40ea56411a25c87b574b0d699d6af674fde1" + ); + assert_eq!( + authorized.body().proposal_id().unwrap().to_string(), + "b3:f0d88014b200765346052222fe45aba219e8fddbc86692aac618440c7417c54d" + ); + assert_eq!( + authorized.event_id().unwrap().to_string(), + "b3:fe5309a3fe9d29f354a0d40622c6c09ea45122b9fcce6531ef6fded48935cf33" + ); + assert_eq!( + authorized + .admission_evidence() + .admission_evidence_id() + .unwrap() + .to_string(), + "b3:e02065abe46435c7aebf6928ca7aa94321b0e63dea3e1f65180b9c72e433059e" + ); + assert_eq!( + approval + .body() + .controller_approval_id() + .unwrap() + .to_string(), + "b3:6514d38bc0a5d12dd3603238a31c7a5982562f32c28503f0f5ba51a2f6673f39" + ); +} + +#[test] +fn checkpoint_authorization_vectors_cover_both_modes() { + let body = checkpoint_body(); + let checkpoint_id = body.checkpoint_id().unwrap(); + let approval = SignedControllerApproval::new( + ControllerApprovalBody::checkpoint( + typed_id::(83), + checkpoint_id, + Extensions::default(), + ) + .unwrap(), + vec![keyed_signature(84)], + ) + .unwrap(); + let controllers = CheckpointAuthorization::controllers( + checkpoint_id, + ControllerApprovals::new(vec![approval]).unwrap(), + ) + .unwrap(); + let transition_event = transition_authorized_event(); + let transition = CheckpointAuthorization::transition_derived(&transition_event).unwrap(); + let witness = transition.transition_witness().unwrap(); + let controller_signed = SignedCheckpoint::new(body.clone(), controllers.clone()).unwrap(); + let transition_signed = SignedCheckpoint::new( + retired_checkpoint_body(transition_event.event_id().unwrap()), + transition.clone(), + ) + .unwrap(); + + check_vector( + "checkpoint_authorization_controllers", + &controllers, + "01010101535353535353535353535353535353535353535353535353535353535353535302012ec9928a9a1c43fdaf59ac0228675cbb7249d25ea44acd54961e8318f5078e4e0001013c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c013d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d014054545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454", + ); + check_vector( + "checkpoint_authorization_transition", + &transition, + "02010201244a01e7a028943953b80ebfd13eea75600c325e419d9e3a655c74615e3e39ad010246eaea6cedb35c757369da02d4c095004ecfb28f77e0ea98bb436538f15651", + ); + check_vector( + "transition_checkpoint_witness", + witness, + "010201244a01e7a028943953b80ebfd13eea75600c325e419d9e3a655c74615e3e39ad010246eaea6cedb35c757369da02d4c095004ecfb28f77e0ea98bb436538f15651", + ); + check_vector( + "signed_checkpoint_controllers", + &controller_signed, + "010101010101010101010101010101010101010101010101010101010101010101010203010404040404040404040404040404040404040404040404040404040404040404010505050505050505050505050505050505050505050505050505050505050505010606060606060606060606060606060606060606060606060606060606060606010707070707070707070707070707070707070707070707070707070707070707010808080808080808080808080808080808080808080808080808080808080808010909090909090909090909090909090909090909090909090909090909090909010a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a010b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b010c0001010101535353535353535353535353535353535353535353535353535353535353535302012ec9928a9a1c43fdaf59ac0228675cbb7249d25ea44acd54961e8318f5078e4e0001013c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c013d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d014054545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454", + ); + check_vector( + "signed_checkpoint_transition", + &transition_signed, + "01010101010101010101010101010101010101010101010101010101010101010101020301244a01e7a028943953b80ebfd13eea75600c325e419d9e3a655c74615e3e39ad010505050505050505050505050505050505050505050505050505050505050505010606060606060606060606060606060606060606060606060606060606060606010707070707070707070707070707070707070707070707070707070707070707010808080808080808080808080808080808080808080808080808080808080808010909090909090909090909090909090909090909090909090909090909090909010a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a010b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b060c0002010201244a01e7a028943953b80ebfd13eea75600c325e419d9e3a655c74615e3e39ad010246eaea6cedb35c757369da02d4c095004ecfb28f77e0ea98bb436538f15651", + ); + assert_eq!( + checkpoint_id.to_string(), + "b3:2ec9928a9a1c43fdaf59ac0228675cbb7249d25ea44acd54961e8318f5078e4e" + ); + assert_eq!( + transition_event + .event_authorization_id() + .unwrap() + .to_string(), + "b3:0246eaea6cedb35c757369da02d4c095004ecfb28f77e0ea98bb436538f15651" + ); +} + +#[test] +fn recovery_operation_and_signed_evidence_vectors_are_frozen() { + let (begin, veto, cancel, finalize) = recovery_operations(); + let (body, signed, approvals, evidence) = guardian_evidence(); + + check_vector( + "begin_recovery", + &begin, + "010001f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101010101010101010101010101010101010101010101010101010101010101010101010202020202020202020202020202020202020202020202020202020202020202010303030303030303030303030303030303030303030303030303030303030303010404040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505010101d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a010101000001010a0101000001000000010001050101010000010064e8070000d00f0000010104040404040404040404040404040404040404040404040404040404040404040400", + ); + check_vector( + "veto_recovery", + &veto, + "0101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0123232323232323232323232323232323232323232323232323232323232323230101020202020202020202020202020202020202020202020202020202020202020200", + ); + check_vector( + "cancel_recovery", + &cancel, + "0101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc01010404040404040404040404040404040404040404040404040404040404040404040101020202020202020202020202020202020202020202020202020202020202020200", + ); + check_vector( + "finalize_recovery", + &finalize, + "0101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101010101010101010101010101010101010101010101010101010101010101010101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc01242424242424242424242424242424242424242424242424242424242424242401252525252525252525252525252525252525252525252525252525252525252501ac020101011f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f01202020202020202020202020202020202020202020202020202020202020202001010101010101010101010101010101010101010101010101010101010101010102012424242424242424242424242424242424242424242424242424242424242424ac0200000001011f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f0120202020202020202020202020202020202020202020202020202020202020200001012121212121212121212121212121212121212121212121212121212121212121ad0200012222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222200900300", + ); + check_vector( + "guardian_approval_body", + &body, + "0101010101010101010101010101010101010101010101010101010101010101010101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e00f40300", + ); + check_vector( + "signed_guardian_approval", + &signed, + "0101010101010101010101010101010101010101010101010101010101010101010101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e00f403000101db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e01010101010101010101010101010101010101010101010101010101010101010101010404040404040404040404040404040404040404040404040404040404040404014646464646464646464646464646464646464646464646464646464646464646013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c010001e8070047474747474747474747474747474747474747474747474747474747474747470148484848484848484848484848484848484848484848484848484848484848480000000149494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949", + ); + check_vector( + "guardian_approval_set", + &approvals, + "010101010101010101010101010101010101010101010101010101010101010101010101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e00f403000101db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e01010101010101010101010101010101010101010101010101010101010101010101010404040404040404040404040404040404040404040404040404040404040404014646464646464646464646464646464646464646464646464646464646464646013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c010001e8070047474747474747474747474747474747474747474747474747474747474747470148484848484848484848484848484848484848484848484848484848484848480000000149494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949", + ); + check_vector( + "guardian_threshold_evidence", + &evidence, + "0201040404040404040404040404040404040404040404040404040404040404040404010101010101010101010101010101010101010101010101010101010101010101010101f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc0101db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e00f403000101db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e01010101010101010101010101010101010101010101010101010101010101010101010404040404040404040404040404040404040404040404040404040404040404014646464646464646464646464646464646464646464646464646464646464646013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c010001e8070047474747474747474747474747474747474747474747474747474747474747470148484848484848484848484848484848484848484848484848484848484848480000000149494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949494949", + ); + assert_eq!( + begin.recovery_id().to_string(), + "b3:f7bf3243431c5b8cf610cb04f668c90f537419e0338daee80de4de6d8d647adc" + ); + assert_eq!( + signed.opening().guardian_grant_id().to_string(), + "b3:db8d671e2e8011e4b3ba98f0c521b30f2aa52322f74eb1f82da20a6def02074e" + ); +} diff --git a/protocols/krikos-identity/tests/transparency_admission.rs b/protocols/krikos-identity/tests/transparency_admission.rs new file mode 100644 index 00000000000..0c0dfe0ef6a --- /dev/null +++ b/protocols/krikos-identity/tests/transparency_admission.rs @@ -0,0 +1,193 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountGenesis, AccountOperation, AccountState, AlgorithmSignature, CanonicalWire, + ControlPolicy, ControllerClass, ControllerDescriptor, ControllerKeyId, ControllerScope, + ControllerSelector, ControllerThreshold, ControllerWeight, CryptoSuiteDescriptor, Digest, + DurationMillis, EventBody, EventIntentApprovalBody, EventIntentApprovals, EventPredecessors, + Extensions, FreshnessRequirement, HashAlgorithm, IdentityError, KeyedSignature, + MemoryTransparencyLog, OperationKind, PolicyRule, ProtocolSignature, ProviderDescriptor, + ProviderHeadSigner, ProviderLogId, ProviderLogSubject, ProviderPolicy, ProviderPolicyVersion, + ProviderQuorum, RecoveryAuthority, RecoveryPolicy, RecoveryPolicyVersion, RequiredWeight, + SignedEventIntentApproval, SigningPublicKey, Timestamp, verify_event_intent_admission, +}; + +fn typed_id(fill: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [fill; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn controller(secret: &SecretKey) -> ControllerDescriptor { + ControllerDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + ControllerClass::PersonalDevice, + ControllerWeight::new(1).unwrap(), + ControllerScope::all_v1_operations(), + Extensions::default(), + ) + .unwrap() +} + +fn intent_approval( + controller_id: krikos_identity::ControllerId, + proposal_id: krikos_identity::ProposalId, + signer: &SecretKey, +) -> SignedEventIntentApproval { + let body = + EventIntentApprovalBody::new(controller_id, proposal_id, Extensions::default()).unwrap(); + let signing_key = SigningPublicKey::ed25519(*signer.public().as_bytes()).unwrap(); + let signature = signer.sign(&body.to_canonical_bytes().unwrap()); + SignedEventIntentApproval::new( + body, + vec![KeyedSignature::new( + CryptoSuiteDescriptor::v1() + .unwrap() + .crypto_suite_id() + .unwrap(), + ControllerKeyId::for_signing_key(&signing_key).unwrap(), + AlgorithmSignature::new(1, signature.to_bytes().to_vec()).unwrap(), + )], + ) + .unwrap() +} + +struct Signer(SecretKey); + +impl ProviderHeadSigner for Signer { + fn sign_provider_head(&self, message: &[u8]) -> Result { + Ok(ProtocolSignature::ed25519(self.0.sign(message).to_bytes())) + } +} + +#[test] +fn provider_appends_only_exact_pre_state_threshold_approved_delayed_intent() { + let first_secret = SecretKey::from_bytes(&[0x91; 32]); + let second_secret = SecretKey::from_bytes(&[0x92; 32]); + let first = controller(&first_secret); + let second = controller(&second_secret); + let policy = ControlPolicy::new( + vec![ + PolicyRule::new( + OperationKind::AddController, + RequiredWeight::new(2).unwrap(), + ControllerSelector::any_active(), + FreshnessRequirement::latest_known(), + Some(DurationMillis::new(10)), + Extensions::default(), + ) + .unwrap(), + ], + Extensions::default(), + ) + .unwrap(); + let recovery = RecoveryPolicy::new( + RecoveryPolicyVersion::GENESIS, + RecoveryAuthority::controller_threshold(ControllerThreshold::new( + ControllerSelector::any_active(), + RequiredWeight::new(1).unwrap(), + )), + DurationMillis::new(10), + DurationMillis::new(100), + Extensions::default(), + ) + .unwrap(); + let provider_secret = SecretKey::from_bytes(&[0x93; 32]); + let provider = ProviderDescriptor::new( + SigningPublicKey::ed25519(*provider_secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap(); + let provider_policy = ProviderPolicy::replicated( + ProviderPolicyVersion::GENESIS, + vec![provider.clone()], + ProviderQuorum::new(1).unwrap(), + ProviderQuorum::new(1).unwrap(), + DurationMillis::new(1_000), + Extensions::default(), + ) + .unwrap(); + let genesis = AccountGenesis::new( + [0x94; 32], + Timestamp::from_unix_millis(1), + policy, + vec![first, second], + recovery, + provider_policy, + Extensions::default(), + ) + .unwrap(); + let state = AccountState::from_genesis(&genesis).unwrap(); + let operation = + AccountOperation::AddController(controller(&SecretKey::from_bytes(&[0x95; 32]))); + let body = EventBody::new( + state.account_id(), + state.sequence().checked_next().unwrap(), + state.expected_epoch_for(&operation).unwrap(), + EventPredecessors::genesis(state.genesis_anchor()), + operation, + Timestamp::from_unix_millis(2), + [0x96; 16], + Extensions::default(), + ) + .unwrap(); + let proposal_id = body.proposal_id().unwrap(); + let first_id = state + .active_controllers() + .iter() + .find(|entry| entry.signing_key().as_bytes() == first_secret.public().as_bytes()) + .unwrap() + .id(); + let second_id = state + .active_controllers() + .iter() + .find(|entry| entry.signing_key().as_bytes() == second_secret.public().as_bytes()) + .unwrap() + .id(); + let first_approval = intent_approval(first_id, proposal_id, &first_secret); + let second_approval = intent_approval(second_id, proposal_id, &second_secret); + let approvals = + EventIntentApprovals::new(vec![first_approval.clone(), second_approval]).unwrap(); + let before = state.clone(); + let admission = verify_event_intent_admission(&state, &body, &approvals).unwrap(); + assert_eq!(state, before); + assert_eq!(admission.account_id(), state.account_id()); + assert_eq!( + admission.subject(), + ProviderLogSubject::EventIntent(proposal_id) + ); + + let mut log = MemoryTransparencyLog::new(provider.clone(), typed_id::(0x97)); + let receipt = log + .append( + admission.clone(), + Timestamp::from_unix_millis(10), + &Signer(provider_secret), + ) + .unwrap(); + receipt.verify(&provider).unwrap(); + assert_eq!(receipt.entry().subject(), admission.subject()); + + let insufficient = EventIntentApprovals::new(vec![first_approval]).unwrap(); + assert_eq!( + verify_event_intent_admission(&state, &body, &insufficient), + Err(IdentityError::AuthorizationDenied) + ); + let forged = EventIntentApprovals::new(vec![ + intent_approval(first_id, proposal_id, &SecretKey::from_bytes(&[0x98; 32])), + intent_approval(second_id, proposal_id, &second_secret), + ]) + .unwrap(); + assert_eq!( + verify_event_intent_admission(&state, &body, &forged), + Err(IdentityError::InvalidSignature) + ); + let wrong_proposal = typed_id::(0x99); + let unrelated = EventIntentApprovals::new(vec![ + intent_approval(first_id, wrong_proposal, &first_secret), + intent_approval(second_id, wrong_proposal, &second_secret), + ]) + .unwrap(); + assert!(matches!( + verify_event_intent_admission(&state, &body, &unrelated), + Err(IdentityError::InvalidRelationship { .. }) + )); +} diff --git a/protocols/krikos-identity/tests/transparency_crypto.rs b/protocols/krikos-identity/tests/transparency_crypto.rs new file mode 100644 index 00000000000..08f8cc0ae6d --- /dev/null +++ b/protocols/krikos-identity/tests/transparency_crypto.rs @@ -0,0 +1,379 @@ +use krikos_base::SecretKey; +use krikos_identity::{ + AccountId, CanonicalWire, CheckpointId, Digest, Extensions, HashAlgorithm, IdentityError, + InclusionReceipt, ProtocolSignature, ProviderDescriptor, ProviderEquivocationEvidence, + ProviderHeadAuditDisposition, ProviderHeadAuditor, ProviderHeadBody, ProviderKeyVersion, + ProviderLogEntryBody, ProviderLogId, ProviderLogSubject, SignedProviderHead, SigningPublicKey, + Timestamp, merkle::AppendOnlyMerkleLog, verify_provider_head_progression, +}; + +fn typed_id(seed: u8) -> T { + let digest = Digest::new(HashAlgorithm::Blake3_256, [seed; 32]); + T::from_canonical_bytes(&digest.to_canonical_bytes().unwrap()).unwrap() +} + +fn provider_descriptor(secret: &SecretKey) -> ProviderDescriptor { + ProviderDescriptor::new( + SigningPublicKey::ed25519(*secret.public().as_bytes()).unwrap(), + Extensions::default(), + ) + .unwrap() +} + +fn entry(provider: &ProviderDescriptor, observed_at: u64) -> ProviderLogEntryBody { + ProviderLogEntryBody::new( + provider.id().unwrap(), + typed_id::(0x42), + typed_id::(0x43), + ProviderLogSubject::Checkpoint(typed_id::(0x44)), + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap() +} + +fn signed_head( + secret: &SecretKey, + provider: &ProviderDescriptor, + tree_root: Digest, + tree_size: u64, + observed_at: u64, +) -> SignedProviderHead { + let body = ProviderHeadBody::new( + provider.id().unwrap(), + typed_id::(0x42), + ProviderKeyVersion::GENESIS, + tree_size, + tree_root, + Timestamp::from_unix_millis(observed_at), + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&body.signing_bytes().unwrap()); + SignedProviderHead::new(body, ProtocolSignature::ed25519(signature.to_bytes())) +} + +#[test] +fn provider_head_signature_and_single_leaf_receipt_verify() { + let secret = SecretKey::from_bytes(&[0x71; 32]); + let provider = provider_descriptor(&secret); + let entry = entry(&provider, 100); + let root = entry.merkle_leaf_hash().unwrap(); + let head = signed_head(&secret, &provider, root, 1, 105); + let receipt = InclusionReceipt::new(entry, 0, Vec::new(), head).unwrap(); + + receipt.verify(&provider).unwrap(); + assert_eq!(receipt.leaf_index(), 0); + assert_eq!(receipt.signed_head().body().tree_root(), root); + assert!( + receipt + .signed_head() + .body() + .signing_bytes() + .unwrap() + .starts_with(b"KRIKOS-ID/provider-head-signature/v1\0") + ); +} + +#[test] +fn provider_receipt_rejects_signature_root_path_and_time_substitution() { + let secret = SecretKey::from_bytes(&[0x72; 32]); + let provider = provider_descriptor(&secret); + let entry = entry(&provider, 200); + let leaf = entry.merkle_leaf_hash().unwrap(); + + let invalid_signature = InclusionReceipt::new( + entry.clone(), + 0, + Vec::new(), + SignedProviderHead::new( + signed_head(&secret, &provider, leaf, 1, 210).body().clone(), + ProtocolSignature::ed25519([0; 64]), + ), + ) + .unwrap(); + assert_eq!( + invalid_signature.verify(&provider), + Err(IdentityError::InvalidSignature) + ); + + let wrong_root = Digest::new(HashAlgorithm::Blake3_256, [0x99; 32]); + let bad_root = InclusionReceipt::new( + entry.clone(), + 0, + Vec::new(), + signed_head(&secret, &provider, wrong_root, 1, 210), + ) + .unwrap(); + assert_eq!(bad_root.verify(&provider), Err(IdentityError::InvalidProof)); + + let extra_path = InclusionReceipt::new( + entry.clone(), + 0, + vec![leaf], + signed_head(&secret, &provider, leaf, 1, 210), + ) + .unwrap(); + assert_eq!( + extra_path.verify(&provider), + Err(IdentityError::InvalidProof) + ); + + let backwards_time = InclusionReceipt::new( + entry, + 0, + Vec::new(), + signed_head(&secret, &provider, leaf, 1, 199), + ) + .unwrap(); + assert!(matches!( + backwards_time.verify(&provider), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn provider_receipt_rejects_wrong_descriptor_and_key_version() { + let secret = SecretKey::from_bytes(&[0x73; 32]); + let provider = provider_descriptor(&secret); + let entry = entry(&provider, 300); + let leaf = entry.merkle_leaf_hash().unwrap(); + let receipt = InclusionReceipt::new( + entry.clone(), + 0, + Vec::new(), + signed_head(&secret, &provider, leaf, 1, 301), + ) + .unwrap(); + let other = provider_descriptor(&SecretKey::from_bytes(&[0x74; 32])); + assert!(matches!( + receipt.verify(&other), + Err(IdentityError::InvalidRelationship { .. }) + )); + + let body = ProviderHeadBody::new( + provider.id().unwrap(), + entry.log_id(), + ProviderKeyVersion::new(1), + 1, + leaf, + Timestamp::from_unix_millis(301), + Extensions::default(), + ) + .unwrap(); + let signature = secret.sign(&body.signing_bytes().unwrap()); + let wrong_version = InclusionReceipt::new( + entry, + 0, + Vec::new(), + SignedProviderHead::new(body, ProtocolSignature::ed25519(signature.to_bytes())), + ) + .unwrap(); + assert!(matches!( + wrong_version.verify(&provider), + Err(IdentityError::InvalidRelationship { .. }) + )); +} + +#[test] +fn signed_head_progression_detects_rollback_and_durable_equivocation() { + let secret = SecretKey::from_bytes(&[0x75; 32]); + let provider = provider_descriptor(&secret); + let mut log = AppendOnlyMerkleLog::new(); + log.append(entry(&provider, 400).merkle_leaf_hash().unwrap()) + .unwrap(); + let first_root = log.root().unwrap(); + let first = signed_head(&secret, &provider, first_root, 1, 401); + log.append(entry(&provider, 402).merkle_leaf_hash().unwrap()) + .unwrap(); + let second = signed_head(&secret, &provider, log.root().unwrap(), 2, 403); + + verify_provider_head_progression( + &provider, + &first, + &second, + &log.consistency_proof(1).unwrap(), + ) + .unwrap(); + assert_eq!( + verify_provider_head_progression( + &provider, + &second, + &first, + &log.consistency_proof(1).unwrap(), + ), + Err(IdentityError::ProviderRollback) + ); + + let conflicting = signed_head(&secret, &provider, digest(0x99), 2, 404); + assert_eq!( + verify_provider_head_progression( + &provider, + &second, + &conflicting, + &log.consistency_proof(2).unwrap(), + ), + Err(IdentityError::ProviderEquivocation) + ); + let evidence = ProviderEquivocationEvidence::new(&provider, second, conflicting).unwrap(); + let encoded = evidence.to_canonical_bytes().unwrap(); + assert_eq!( + ProviderEquivocationEvidence::from_canonical_bytes(&encoded).unwrap(), + evidence + ); +} + +#[test] +fn auditor_pins_log_generation_and_retains_first_equivocation() { + let secret = SecretKey::from_bytes(&[0x76; 32]); + let provider = provider_descriptor(&secret); + let log_id = typed_id::(0x42); + let mut log = AppendOnlyMerkleLog::new(); + log.append(entry(&provider, 500).merkle_leaf_hash().unwrap()) + .unwrap(); + let first = signed_head(&secret, &provider, log.root().unwrap(), 1, 501); + let mut auditor = ProviderHeadAuditor::new(provider.clone(), log_id); + assert_eq!( + auditor.observe(first.clone(), None).unwrap(), + ProviderHeadAuditDisposition::FirstObserved + ); + + let wrong_log_body = ProviderHeadBody::new( + provider.id().unwrap(), + typed_id::(0x77), + ProviderKeyVersion::GENESIS, + 1, + log.root().unwrap(), + Timestamp::from_unix_millis(502), + Extensions::default(), + ) + .unwrap(); + let wrong_log_signature = secret.sign(&wrong_log_body.signing_bytes().unwrap()); + let before_wrong_log = auditor.clone(); + assert!(matches!( + auditor.observe( + SignedProviderHead::new( + wrong_log_body, + ProtocolSignature::ed25519(wrong_log_signature.to_bytes()), + ), + None, + ), + Err(IdentityError::InvalidRelationship { .. }) + )); + assert_eq!(auditor, before_wrong_log); + + let refreshed = signed_head(&secret, &provider, log.root().unwrap(), 1, 503); + assert_eq!( + auditor.observe(refreshed, None).unwrap(), + ProviderHeadAuditDisposition::HeadRefreshed + ); + log.append(entry(&provider, 504).merkle_leaf_hash().unwrap()) + .unwrap(); + let advanced = signed_head(&secret, &provider, log.root().unwrap(), 2, 505); + assert_eq!( + auditor + .observe(advanced.clone(), Some(&log.consistency_proof(1).unwrap())) + .unwrap(), + ProviderHeadAuditDisposition::TreeAdvanced + ); + let before_rollback = auditor.clone(); + assert_eq!( + auditor.observe(first, None), + Err(IdentityError::ProviderRollback) + ); + assert_eq!(auditor, before_rollback); + + let conflicting = signed_head(&secret, &provider, digest(0x9a), 2, 506); + assert_eq!( + auditor.observe(conflicting, None), + Err(IdentityError::ProviderEquivocation) + ); + auditor + .equivocation_evidence() + .unwrap() + .verify(&provider) + .unwrap(); + assert_eq!(auditor.latest_head(), Some(&advanced)); + assert_eq!( + auditor.observe(advanced, None), + Err(IdentityError::ProviderEquivocation) + ); +} + +fn digest(fill: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [fill; 32]) +} + +#[test] +fn provider_leaf_head_and_append_proofs_match_frozen_vectors() { + fn digest_hex(value: &str) -> Digest { + let bytes: [u8; 32] = hex::decode(value).unwrap().try_into().unwrap(); + Digest::new(HashAlgorithm::Blake3_256, bytes) + } + + fn raw_hash(domain: &[u8], payload: &[u8]) -> Digest { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(&[0]); + hasher.update(payload); + Digest::new(HashAlgorithm::Blake3_256, *hasher.finalize().as_bytes()) + } + + let secret = SecretKey::from_bytes(&[0x71; 32]); + let provider = provider_descriptor(&secret); + let first_entry = entry(&provider, 100); + let entry_wire = first_entry.to_canonical_bytes().unwrap(); + assert_eq!( + hex::encode(&entry_wire), + "0101b3cee1bf5a7e0941686c9d3395a04086c25b94838b272b4da873ea39d6fb5ba0014242424242424242424242424242424242424242424242424242424242424242014343434343434343434343434343434343434343434343434343434343434343010144444444444444444444444444444444444444444444444444444444444444446400" + ); + let first_leaf = first_entry.merkle_leaf_hash().unwrap(); + assert_eq!( + first_leaf, + digest_hex("e26e2eefa64f1da02e71fa98226c34fe43e307cfa149c6bb355e168656a33c44") + ); + assert_eq!( + first_leaf, + raw_hash(b"KRIKOS-ID/provider-log-entry/v1", &entry_wire) + ); + + let first_head = signed_head(&secret, &provider, first_leaf, 1, 105); + assert_eq!( + hex::encode(first_head.body().to_canonical_bytes().unwrap()), + "0101b3cee1bf5a7e0941686c9d3395a04086c25b94838b272b4da873ea39d6fb5ba0014242424242424242424242424242424242424242424242424242424242424242000101e26e2eefa64f1da02e71fa98226c34fe43e307cfa149c6bb355e168656a33c446900" + ); + assert_eq!( + hex::encode(first_head.body().signing_bytes().unwrap()), + "4b52494b4f532d49442f70726f76696465722d686561642d7369676e61747572652f7631000101b3cee1bf5a7e0941686c9d3395a04086c25b94838b272b4da873ea39d6fb5ba0014242424242424242424242424242424242424242424242424242424242424242000101e26e2eefa64f1da02e71fa98226c34fe43e307cfa149c6bb355e168656a33c446900" + ); + assert_eq!( + hex::encode(first_head.signature().as_bytes()), + "af72f387a09078cc766e7b6afa7cfbdb6be6d4c1a3ced76fe1534332a89a642de3e9919ae0dd072c36cddee99e7a5f0a80984d3918f29865c39e714d022ac50d" + ); + first_head.verify(&provider).unwrap(); + + let second_entry = entry(&provider, 101); + let second_leaf = second_entry.merkle_leaf_hash().unwrap(); + assert_eq!( + second_leaf, + digest_hex("bb7957c02cd486f406209c28dd8ee911d1b4c9f733239cdb67f56af028323721") + ); + let log = AppendOnlyMerkleLog::from_leaf_hashes(vec![first_leaf, second_leaf]).unwrap(); + let expected_root = + digest_hex("0e54dda3746ab990a6449e27d1e8474546e3c365fad260ce0bc5cd2b66a2c9fe"); + assert_eq!(log.root().unwrap(), expected_root); + let mut node_payload = Vec::with_capacity(66); + node_payload.push(1); + node_payload.extend_from_slice(first_leaf.as_bytes()); + node_payload.push(1); + node_payload.extend_from_slice(second_leaf.as_bytes()); + assert_eq!( + expected_root, + raw_hash(b"KRIKOS-ID/merkle-node/v1", &node_payload) + ); + assert_eq!(log.inclusion_proof(0).unwrap().audit_path(), &[second_leaf]); + assert_eq!( + log.consistency_proof(1).unwrap().audit_path(), + &[second_leaf] + ); +} diff --git a/protocols/krikos-identity/tests/transparency_log.rs b/protocols/krikos-identity/tests/transparency_log.rs new file mode 100644 index 00000000000..9b45de45099 --- /dev/null +++ b/protocols/krikos-identity/tests/transparency_log.rs @@ -0,0 +1,44 @@ +use krikos_identity::{Digest, HashAlgorithm, IdentityError, merkle::AppendOnlyMerkleLog}; + +fn digest(fill: u8) -> Digest { + Digest::new(HashAlgorithm::Blake3_256, [fill; 32]) +} + +#[test] +fn append_log_proves_every_leaf_and_prefix_without_reordering() { + let mut log = AppendOnlyMerkleLog::new(); + let empty_root = log.root().unwrap(); + let leaves = (1_u8..=17).map(digest).collect::>(); + let mut prefix_roots = vec![empty_root]; + + for (expected_index, leaf) in leaves.iter().copied().enumerate() { + assert_eq!(log.append(leaf).unwrap(), expected_index as u64); + prefix_roots.push(log.root().unwrap()); + } + + assert_eq!(log.leaf_hashes(), leaves); + for (index, leaf) in leaves.iter().copied().enumerate() { + log.inclusion_proof(index as u64) + .unwrap() + .verify_leaf_hash(leaf, log.root().unwrap()) + .unwrap(); + } + for old_size in 0..=log.tree_size().unwrap() { + log.consistency_proof(old_size) + .unwrap() + .verify( + prefix_roots[usize::try_from(old_size).unwrap()], + log.root().unwrap(), + ) + .unwrap(); + } +} + +#[test] +fn append_log_rejects_out_of_range_queries_without_mutation() { + let log = AppendOnlyMerkleLog::from_leaf_hashes(vec![digest(1), digest(2)]).unwrap(); + let before = log.clone(); + assert_eq!(log.inclusion_proof(2), Err(IdentityError::InvalidProof)); + assert_eq!(log.consistency_proof(3), Err(IdentityError::InvalidProof)); + assert_eq!(log, before); +} diff --git a/protocols/krikos-identity/tests/vectors.rs b/protocols/krikos-identity/tests/vectors.rs new file mode 100644 index 00000000000..e22b2777203 --- /dev/null +++ b/protocols/krikos-identity/tests/vectors.rs @@ -0,0 +1,271 @@ +use krikos_identity::{ + AeadAlgorithm, AgreementAlgorithm, AgreementPublicKey, CanonicalWire, Digest, Epoch, Extension, + Extensions, HashAlgorithm, IdentityError, KdfAlgorithm, OperationKind, ProtocolSignature, + ProtocolVersion, RESERVED_PUBLISH_CHECKPOINT_CODE, Sequence, SignatureAlgorithm, + SigningPublicKey, Timestamp, + limits::{MAX_ENCODED_OBJECT_BYTES, MAX_EXTENSIONS, MAX_TOTAL_EXTENSION_BYTES}, +}; + +const VALID_ED25519_KEY: [u8; 32] = [ + 0xae, 0x58, 0xff, 0x88, 0x33, 0x24, 0x1a, 0xc8, 0x2d, 0x6f, 0xf7, 0x61, 0x10, 0x46, 0xed, 0x67, + 0xb5, 0x07, 0x2d, 0x14, 0x2c, 0x58, 0x8d, 0x00, 0x63, 0xe9, 0x42, 0xd9, 0xa7, 0x55, 0x02, 0xb6, +]; + +#[test] +fn v1_algorithm_codepoints_are_frozen() { + assert_eq!(HashAlgorithm::Blake3_256.code(), 1); + assert_eq!(SignatureAlgorithm::Ed25519.code(), 1); + assert_eq!(AgreementAlgorithm::X25519.code(), 1); + assert_eq!(KdfAlgorithm::Blake3DeriveKey.code(), 1); + assert_eq!(AeadAlgorithm::XChaCha20Poly1305.code(), 1); +} + +#[test] +fn v1_operation_codepoints_are_frozen() { + let expected = [ + (OperationKind::AuthorizeDevice, 1), + (OperationKind::UpdateDeviceAuthorization, 2), + (OperationKind::UpdateDeviceMetadata, 3), + (OperationKind::SuspendDevice, 4), + (OperationKind::ReinstateDevice, 5), + (OperationKind::RevokeDevice, 6), + (OperationKind::RotateDeviceKeys, 7), + (OperationKind::AddController, 8), + (OperationKind::RemoveController, 9), + (OperationKind::ChangeControlPolicy, 10), + (OperationKind::ChangeRecoveryPolicy, 11), + (OperationKind::ChangeProviderPolicy, 12), + (OperationKind::BeginRecovery, 13), + (OperationKind::VetoRecovery, 14), + (OperationKind::CancelRecovery, 15), + (OperationKind::FinalizeRecovery, 16), + (OperationKind::ResolveFork, 17), + (OperationKind::BeginCryptoMigration, 18), + (OperationKind::ActivateCryptoMigration, 19), + (OperationKind::RetireCryptoSuite, 20), + (OperationKind::UpgradeProtocol, 21), + (OperationKind::RetireAccount, 22), + ]; + for (kind, code) in expected { + assert_eq!(kind.code(), code); + assert_eq!(OperationKind::from_code(code).unwrap(), kind); + } + assert_eq!(RESERVED_PUBLISH_CHECKPOINT_CODE, 23); + assert!(matches!( + OperationKind::from_code(RESERVED_PUBLISH_CHECKPOINT_CODE), + Err(IdentityError::ReservedCodepoint { code: 23, .. }) + )); +} + +#[test] +fn extensions_are_sorted_bounded_and_preserve_noncritical_unknown_values() { + let extensions = Extensions::new(vec![ + Extension::new(9, false, vec![9, 8]).unwrap(), + Extension::new(2, false, vec![2]).unwrap(), + ]) + .unwrap(); + assert_eq!(extensions.as_slice()[0].code(), 2); + assert_eq!(extensions.as_slice()[1].code(), 9); + + let encoded = extensions.to_canonical_bytes().unwrap(); + assert_eq!(encoded, [2, 2, 0, 1, 2, 9, 0, 2, 9, 8]); + assert_eq!( + Extensions::from_canonical_bytes(&encoded).unwrap(), + extensions + ); + extensions.validate_critical(&[2]).unwrap(); + + let critical = Extensions::new(vec![Extension::new(7, true, vec![]).unwrap()]).unwrap(); + assert!(matches!( + critical.validate_critical(&[2]), + Err(IdentityError::UnknownCriticalExtension { code: 7 }) + )); + + let duplicates = vec![ + Extension::new(1, false, vec![]).unwrap(), + Extension::new(1, false, vec![1]).unwrap(), + ]; + assert!(matches!( + Extensions::new(duplicates), + Err(IdentityError::DuplicateExtension { code: 1 }) + )); + + let too_many = (1..=u32::try_from(MAX_EXTENSIONS + 1).unwrap()) + .map(|code| Extension::new(code, false, vec![]).unwrap()) + .collect(); + assert!(matches!( + Extensions::new(too_many), + Err(IdentityError::LimitExceeded { + maximum: MAX_EXTENSIONS, + .. + }) + )); + + assert!(Extension::new(1, false, vec![0; MAX_TOTAL_EXTENSION_BYTES + 1]).is_err()); + + let unsorted_wire = [2, 2, 0, 0, 1, 0, 0]; + assert!(matches!( + Extensions::from_canonical_bytes(&unsorted_wire), + Err(IdentityError::NonCanonical) + )); + + let duplicate_wire = [2, 1, 0, 0, 1, 0, 0]; + assert!(matches!( + Extensions::from_canonical_bytes(&duplicate_wire), + Err(IdentityError::DuplicateExtension { code: 1 }) + )); +} + +#[test] +fn canonical_foundation_vectors_are_frozen() { + let digest = Digest::new(HashAlgorithm::Blake3_256, [0xab; 32]); + let mut expected_digest = vec![1]; + expected_digest.extend_from_slice(&[0xab; 32]); + assert_eq!(digest.to_canonical_bytes().unwrap(), expected_digest); + assert_eq!( + Digest::from_canonical_bytes(&expected_digest).unwrap(), + digest + ); + + let key = SigningPublicKey::ed25519(VALID_ED25519_KEY).unwrap(); + let mut expected_key = vec![1]; + expected_key.extend_from_slice(&VALID_ED25519_KEY); + assert_eq!(key.to_canonical_bytes().unwrap(), expected_key); + + let mut agreement_bytes = [0; 32]; + agreement_bytes[0] = 9; + let agreement_key = AgreementPublicKey::x25519(agreement_bytes).unwrap(); + let mut expected_agreement_key = vec![1]; + expected_agreement_key.extend_from_slice(&agreement_bytes); + assert_eq!( + agreement_key.to_canonical_bytes().unwrap(), + expected_agreement_key + ); + assert_eq!( + AgreementPublicKey::from_canonical_bytes(&expected_agreement_key).unwrap(), + agreement_key + ); + + let signature = ProtocolSignature::ed25519([0x5a; 64]); + let mut expected_signature = vec![1]; + expected_signature.extend_from_slice(&[0x5a; 64]); + assert_eq!(signature.to_canonical_bytes().unwrap(), expected_signature); + + assert_eq!(ProtocolVersion::V1.to_canonical_bytes().unwrap(), [1]); +} + +#[test] +fn canonical_decode_rejects_trailing_and_noncanonical_bytes() { + let digest = Digest::new(HashAlgorithm::Blake3_256, [7; 32]); + let mut trailing = digest.to_canonical_bytes().unwrap(); + trailing.push(0); + assert!(matches!( + Digest::from_canonical_bytes(&trailing), + Err(IdentityError::NonCanonical) + )); + + let mut overlong_algorithm = vec![0x81, 0x00]; + overlong_algorithm.extend_from_slice(&[7; 32]); + assert!(matches!( + Digest::from_canonical_bytes(&overlong_algorithm), + Err(IdentityError::NonCanonical) + )); +} + +#[test] +fn canonical_decode_rejects_unknown_codepoints_and_oversized_input() { + let mut unknown_hash = vec![99]; + unknown_hash.extend_from_slice(&[0; 32]); + assert!(matches!( + Digest::from_canonical_bytes(&unknown_hash), + Err(IdentityError::UnsupportedAlgorithm { code: 99, .. }) + )); + + let oversized = vec![0; MAX_ENCODED_OBJECT_BYTES + 1]; + assert!(matches!( + Digest::from_canonical_bytes(&oversized), + Err(IdentityError::LimitExceeded { + maximum: MAX_ENCODED_OBJECT_BYTES, + .. + }) + )); +} + +#[test] +fn x25519_public_keys_are_canonical_and_contributory() { + let mut basepoint = [0; 32]; + basepoint[0] = 9; + assert!(AgreementPublicKey::x25519(basepoint).is_ok()); + + let mut high_bit_alias = basepoint; + high_bit_alias[31] = 0x80; + assert!(matches!( + AgreementPublicKey::x25519(high_bit_alias), + Err(IdentityError::InvalidPublicKey { .. }) + )); + + let mut field_modulus = [0xff; 32]; + field_modulus[0] = 0xed; + field_modulus[31] = 0x7f; + assert!(matches!( + AgreementPublicKey::x25519(field_modulus), + Err(IdentityError::InvalidPublicKey { .. }) + )); + + let mut low_order = [0; 32]; + low_order[0] = 1; + assert!(matches!( + AgreementPublicKey::x25519(low_order), + Err(IdentityError::InvalidPublicKey { .. }) + )); +} + +#[test] +fn ed25519_public_keys_reject_weak_points() { + let mut identity_point = [0; 32]; + identity_point[0] = 1; + assert!(matches!( + SigningPublicKey::ed25519(identity_point), + Err(IdentityError::InvalidPublicKey { .. }) + )); +} + +#[test] +fn aggregate_extension_bound_is_exercised() { + let at_limit = (1..=4) + .map(|code| Extension::new(code, false, vec![0; 16 * 1024]).unwrap()) + .collect(); + assert!(Extensions::new(at_limit).is_ok()); + + let over_limit = (1..=5) + .map(|code| { + let length = if code == 5 { 1 } else { 16 * 1024 }; + Extension::new(code, false, vec![0; length]).unwrap() + }) + .collect(); + assert!(matches!( + Extensions::new(over_limit), + Err(IdentityError::LimitExceeded { + maximum: MAX_TOTAL_EXTENSION_BYTES, + .. + }) + )); +} + +#[test] +fn numeric_newtypes_use_checked_arithmetic() { + assert_eq!(Epoch::GENESIS.checked_next().unwrap().get(), 1); + assert_eq!(Sequence::GENESIS.checked_next().unwrap().get(), 1); + assert!(Epoch::new(u64::MAX).checked_next().is_err()); + assert!(Sequence::new(u64::MAX).checked_next().is_err()); + assert_eq!(Timestamp::from_unix_millis(42).as_unix_millis(), 42); +} + +proptest::proptest! { + #[test] + fn digest_canonical_round_trip(bytes in proptest::array::uniform32(proptest::num::u8::ANY)) { + let value = Digest::new(HashAlgorithm::Blake3_256, bytes); + let encoded = value.to_canonical_bytes().unwrap(); + proptest::prop_assert_eq!(Digest::from_canonical_bytes(&encoded).unwrap(), value); + } +} diff --git a/protocols/krikos-identity/tests/vectors/account-genesis.bin b/protocols/krikos-identity/tests/vectors/account-genesis.bin new file mode 100644 index 00000000000..2153fcc77b2 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-genesis.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-01.bin b/protocols/krikos-identity/tests/vectors/account-operation-01.bin new file mode 100644 index 00000000000..a283c137407 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-01.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-02.bin b/protocols/krikos-identity/tests/vectors/account-operation-02.bin new file mode 100644 index 00000000000..eed79ff845e Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-02.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-03.bin b/protocols/krikos-identity/tests/vectors/account-operation-03.bin new file mode 100644 index 00000000000..71694380742 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-03.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-04.bin b/protocols/krikos-identity/tests/vectors/account-operation-04.bin new file mode 100644 index 00000000000..11e85643145 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-04.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-05.bin b/protocols/krikos-identity/tests/vectors/account-operation-05.bin new file mode 100644 index 00000000000..be77cc7f44b Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-05.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-06.bin b/protocols/krikos-identity/tests/vectors/account-operation-06.bin new file mode 100644 index 00000000000..fff84f65786 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-06.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-07.bin b/protocols/krikos-identity/tests/vectors/account-operation-07.bin new file mode 100644 index 00000000000..3024cff9d41 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-07.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-08.bin b/protocols/krikos-identity/tests/vectors/account-operation-08.bin new file mode 100644 index 00000000000..319c6a53045 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-08.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-09.bin b/protocols/krikos-identity/tests/vectors/account-operation-09.bin new file mode 100644 index 00000000000..2729ac99cdc --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/account-operation-09.bin @@ -0,0 +1 @@ + 55555555555555555555555555555555 \ No newline at end of file diff --git a/protocols/krikos-identity/tests/vectors/account-operation-10.bin b/protocols/krikos-identity/tests/vectors/account-operation-10.bin new file mode 100644 index 00000000000..d6b021b3cd5 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-10.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-11.bin b/protocols/krikos-identity/tests/vectors/account-operation-11.bin new file mode 100644 index 00000000000..7ec75563691 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-11.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-12.bin b/protocols/krikos-identity/tests/vectors/account-operation-12.bin new file mode 100644 index 00000000000..e333d43d51b Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-12.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-13.bin b/protocols/krikos-identity/tests/vectors/account-operation-13.bin new file mode 100644 index 00000000000..3c46c452cb8 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-13.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-14.bin b/protocols/krikos-identity/tests/vectors/account-operation-14.bin new file mode 100644 index 00000000000..825f255f6dc Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-14.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-15.bin b/protocols/krikos-identity/tests/vectors/account-operation-15.bin new file mode 100644 index 00000000000..c163190b3d1 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-15.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-16.bin b/protocols/krikos-identity/tests/vectors/account-operation-16.bin new file mode 100644 index 00000000000..d5e0e78b890 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-16.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-17.bin b/protocols/krikos-identity/tests/vectors/account-operation-17.bin new file mode 100644 index 00000000000..16c19aa3fdf Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-17.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-18.bin b/protocols/krikos-identity/tests/vectors/account-operation-18.bin new file mode 100644 index 00000000000..c8fa5284a6e Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-18.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-19.bin b/protocols/krikos-identity/tests/vectors/account-operation-19.bin new file mode 100644 index 00000000000..c284893e1f5 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-19.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-20.bin b/protocols/krikos-identity/tests/vectors/account-operation-20.bin new file mode 100644 index 00000000000..63d0a2534ed Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-20.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-21.bin b/protocols/krikos-identity/tests/vectors/account-operation-21.bin new file mode 100644 index 00000000000..69b37d3acd5 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-21.bin differ diff --git a/protocols/krikos-identity/tests/vectors/account-operation-22.bin b/protocols/krikos-identity/tests/vectors/account-operation-22.bin new file mode 100644 index 00000000000..3b83f98dd82 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/account-operation-22.bin differ diff --git a/protocols/krikos-identity/tests/vectors/admission-evidence.bin b/protocols/krikos-identity/tests/vectors/admission-evidence.bin new file mode 100644 index 00000000000..d26b90d8de9 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/admission-evidence.bin differ diff --git a/protocols/krikos-identity/tests/vectors/application-event-body.bin b/protocols/krikos-identity/tests/vectors/application-event-body.bin new file mode 100644 index 00000000000..f7accabdf90 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/application-event-body.bin differ diff --git a/protocols/krikos-identity/tests/vectors/authorized-checkpoint-request.bin b/protocols/krikos-identity/tests/vectors/authorized-checkpoint-request.bin new file mode 100644 index 00000000000..94cab613b4f Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/authorized-checkpoint-request.bin differ diff --git a/protocols/krikos-identity/tests/vectors/authorized-event.bin b/protocols/krikos-identity/tests/vectors/authorized-event.bin new file mode 100644 index 00000000000..c2f8ca93018 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/authorized-event.bin differ diff --git a/protocols/krikos-identity/tests/vectors/authorized-proposal-request.bin b/protocols/krikos-identity/tests/vectors/authorized-proposal-request.bin new file mode 100644 index 00000000000..3d5805311fb Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/authorized-proposal-request.bin differ diff --git a/protocols/krikos-identity/tests/vectors/authorized-sync-request.bin b/protocols/krikos-identity/tests/vectors/authorized-sync-request.bin new file mode 100644 index 00000000000..bfc7fd40c79 --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/authorized-sync-request.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MU:|dIq$b @b6bTtvlؙ!I67tU[ 4_c޾|ᓡTcx)( diff --git a/protocols/krikos-identity/tests/vectors/crypto-migration-begin.bin b/protocols/krikos-identity/tests/vectors/crypto-migration-begin.bin new file mode 100644 index 00000000000..ca77315b220 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/crypto-migration-begin.bin differ diff --git a/protocols/krikos-identity/tests/vectors/delegation-body.bin b/protocols/krikos-identity/tests/vectors/delegation-body.bin new file mode 100644 index 00000000000..e23c5cbed32 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/delegation-body.bin differ diff --git a/protocols/krikos-identity/tests/vectors/delegation-chain.bin b/protocols/krikos-identity/tests/vectors/delegation-chain.bin new file mode 100644 index 00000000000..6c03190f8c1 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/delegation-chain.bin differ diff --git a/protocols/krikos-identity/tests/vectors/device-authorization-proposal.bin b/protocols/krikos-identity/tests/vectors/device-authorization-proposal.bin new file mode 100644 index 00000000000..7a864ccbaf0 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/device-authorization-proposal.bin differ diff --git a/protocols/krikos-identity/tests/vectors/endpoint-authorization-request.bin b/protocols/krikos-identity/tests/vectors/endpoint-authorization-request.bin new file mode 100644 index 00000000000..fea42f1f99d --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/endpoint-authorization-request.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MU:UdlB] \ No newline at end of file diff --git a/protocols/krikos-identity/tests/vectors/pairing-confirmation-context.bin b/protocols/krikos-identity/tests/vectors/pairing-confirmation-context.bin new file mode 100644 index 00000000000..8bf8b36e803 --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/pairing-confirmation-context.bin @@ -0,0 +1 @@ +dٳnցG(±!"A580158 \ No newline at end of file diff --git a/protocols/krikos-identity/tests/vectors/pairing-possession-proof.bin b/protocols/krikos-identity/tests/vectors/pairing-possession-proof.bin new file mode 100644 index 00000000000..5df24450f27 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/pairing-possession-proof.bin differ diff --git a/protocols/krikos-identity/tests/vectors/pairing-ticket.bin b/protocols/krikos-identity/tests/vectors/pairing-ticket.bin new file mode 100644 index 00000000000..aae38d1b38a Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/pairing-ticket.bin differ diff --git a/protocols/krikos-identity/tests/vectors/pairing-transcript.bin b/protocols/krikos-identity/tests/vectors/pairing-transcript.bin new file mode 100644 index 00000000000..f8499021ecb Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/pairing-transcript.bin differ diff --git a/protocols/krikos-identity/tests/vectors/portable-credential-body.bin b/protocols/krikos-identity/tests/vectors/portable-credential-body.bin new file mode 100644 index 00000000000..f20dd4fcb4b Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/portable-credential-body.bin differ diff --git a/protocols/krikos-identity/tests/vectors/presence-challenge.bin b/protocols/krikos-identity/tests/vectors/presence-challenge.bin new file mode 100644 index 00000000000..81dfdaff2e9 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/presence-challenge.bin differ diff --git a/protocols/krikos-identity/tests/vectors/presence-proof.bin b/protocols/krikos-identity/tests/vectors/presence-proof.bin new file mode 100644 index 00000000000..0584109f8d1 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/presence-proof.bin differ diff --git a/protocols/krikos-identity/tests/vectors/private-artifact-context.bin b/protocols/krikos-identity/tests/vectors/private-artifact-context.bin new file mode 100644 index 00000000000..154f7f02996 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/private-artifact-context.bin differ diff --git a/protocols/krikos-identity/tests/vectors/private-metadata-envelope.bin b/protocols/krikos-identity/tests/vectors/private-metadata-envelope.bin new file mode 100644 index 00000000000..0f5f1b7ce60 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/private-metadata-envelope.bin differ diff --git a/protocols/krikos-identity/tests/vectors/proposal-endpoint-authorization-request.bin b/protocols/krikos-identity/tests/vectors/proposal-endpoint-authorization-request.bin new file mode 100644 index 00000000000..e536d30ce8b --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/proposal-endpoint-authorization-request.bin @@ -0,0 +1 @@ +4;b s-]?AiP]: diff --git a/protocols/krikos-identity/tests/vectors/provider-audit-export-chunk.bin b/protocols/krikos-identity/tests/vectors/provider-audit-export-chunk.bin new file mode 100644 index 00000000000..f8ad6be3206 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-audit-export-chunk.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-audit-export-manifest.bin b/protocols/krikos-identity/tests/vectors/provider-audit-export-manifest.bin new file mode 100644 index 00000000000..66bfbe14054 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-audit-export-manifest.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-compaction-manifest.bin b/protocols/krikos-identity/tests/vectors/provider-compaction-manifest.bin new file mode 100644 index 00000000000..80d99432795 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-compaction-manifest.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-equivocation-evidence.bin b/protocols/krikos-identity/tests/vectors/provider-equivocation-evidence.bin new file mode 100644 index 00000000000..7d1a8dfd4a2 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-equivocation-evidence.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-export-component-descriptor.bin b/protocols/krikos-identity/tests/vectors/provider-export-component-descriptor.bin new file mode 100644 index 00000000000..4e053a7b292 --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/provider-export-component-descriptor.bin @@ -0,0 +1 @@ +ëLuNS:C"KzG+]hn5 \ No newline at end of file diff --git a/protocols/krikos-identity/tests/vectors/provider-export-component.bin b/protocols/krikos-identity/tests/vectors/provider-export-component.bin new file mode 100644 index 00000000000..d5c254acebc --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/provider-export-component.bin @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/protocols/krikos-identity/tests/vectors/provider-generation-export-chunk.bin b/protocols/krikos-identity/tests/vectors/provider-generation-export-chunk.bin new file mode 100644 index 00000000000..253f53f3243 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-generation-export-chunk.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-generation-export-manifest.bin b/protocols/krikos-identity/tests/vectors/provider-generation-export-manifest.bin new file mode 100644 index 00000000000..ec55ba7999c Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-generation-export-manifest.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-head-body.bin b/protocols/krikos-identity/tests/vectors/provider-head-body.bin new file mode 100644 index 00000000000..3ab9ddc5f06 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-head-body.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-log-entry.bin b/protocols/krikos-identity/tests/vectors/provider-log-entry.bin new file mode 100644 index 00000000000..63c45d0dc50 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-log-entry.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-receipts.bin b/protocols/krikos-identity/tests/vectors/provider-receipts.bin new file mode 100644 index 00000000000..d0a277bd4c6 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-receipts.bin differ diff --git a/protocols/krikos-identity/tests/vectors/provider-recovery-export-manifest.bin b/protocols/krikos-identity/tests/vectors/provider-recovery-export-manifest.bin new file mode 100644 index 00000000000..f8820533156 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/provider-recovery-export-manifest.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recipient-key-wraps.bin b/protocols/krikos-identity/tests/vectors/recipient-key-wraps.bin new file mode 100644 index 00000000000..ca452f8a52a Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recipient-key-wraps.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recovery-authority-plan.bin b/protocols/krikos-identity/tests/vectors/recovery-authority-plan.bin new file mode 100644 index 00000000000..4bf8c7f8ef3 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recovery-authority-plan.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recovery-begin.bin b/protocols/krikos-identity/tests/vectors/recovery-begin.bin new file mode 100644 index 00000000000..6b98d7680b3 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recovery-begin.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recovery-cancel.bin b/protocols/krikos-identity/tests/vectors/recovery-cancel.bin new file mode 100644 index 00000000000..15e1c1edfc2 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recovery-cancel.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recovery-delay-anchor.bin b/protocols/krikos-identity/tests/vectors/recovery-delay-anchor.bin new file mode 100644 index 00000000000..5f4d801bc0d Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recovery-delay-anchor.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recovery-finalize.bin b/protocols/krikos-identity/tests/vectors/recovery-finalize.bin new file mode 100644 index 00000000000..34dbb4e932b Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recovery-finalize.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recovery-proposal.bin b/protocols/krikos-identity/tests/vectors/recovery-proposal.bin new file mode 100644 index 00000000000..2c1ab614667 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recovery-proposal.bin differ diff --git a/protocols/krikos-identity/tests/vectors/recovery-veto.bin b/protocols/krikos-identity/tests/vectors/recovery-veto.bin new file mode 100644 index 00000000000..81503dd880a Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/recovery-veto.bin differ diff --git a/protocols/krikos-identity/tests/vectors/signed-application-event.bin b/protocols/krikos-identity/tests/vectors/signed-application-event.bin new file mode 100644 index 00000000000..fc82ac740e8 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/signed-application-event.bin differ diff --git a/protocols/krikos-identity/tests/vectors/signed-delegation.bin b/protocols/krikos-identity/tests/vectors/signed-delegation.bin new file mode 100644 index 00000000000..8984b202388 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/signed-delegation.bin differ diff --git a/protocols/krikos-identity/tests/vectors/signed-guardian-approval.bin b/protocols/krikos-identity/tests/vectors/signed-guardian-approval.bin new file mode 100644 index 00000000000..c36908659cf Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/signed-guardian-approval.bin differ diff --git a/protocols/krikos-identity/tests/vectors/signed-name-claim.bin b/protocols/krikos-identity/tests/vectors/signed-name-claim.bin new file mode 100644 index 00000000000..6b2bfd297c3 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/signed-name-claim.bin differ diff --git a/protocols/krikos-identity/tests/vectors/signed-portable-credential.bin b/protocols/krikos-identity/tests/vectors/signed-portable-credential.bin new file mode 100644 index 00000000000..85fc125ea2a Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/signed-portable-credential.bin differ diff --git a/protocols/krikos-identity/tests/vectors/signed-provider-head.bin b/protocols/krikos-identity/tests/vectors/signed-provider-head.bin new file mode 100644 index 00000000000..b335b712042 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/signed-provider-head.bin differ diff --git a/protocols/krikos-identity/tests/vectors/signed-social-attestation.bin b/protocols/krikos-identity/tests/vectors/signed-social-attestation.bin new file mode 100644 index 00000000000..5d578c801aa Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/signed-social-attestation.bin differ diff --git a/protocols/krikos-identity/tests/vectors/social-attestation-body.bin b/protocols/krikos-identity/tests/vectors/social-attestation-body.bin new file mode 100644 index 00000000000..1e0ffd40cd4 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/social-attestation-body.bin differ diff --git a/protocols/krikos-identity/tests/vectors/sync-cursor.bin b/protocols/krikos-identity/tests/vectors/sync-cursor.bin new file mode 100644 index 00000000000..131b782aa22 --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/sync-cursor.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MUse _r}Oiku5V\x6:wZIf F[G-A:7ȝ3 \ No newline at end of file diff --git a/protocols/krikos-identity/tests/vectors/sync-frame.bin b/protocols/krikos-identity/tests/vectors/sync-frame.bin new file mode 100644 index 00000000000..76305cfc0a8 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/sync-frame.bin differ diff --git a/protocols/krikos-identity/tests/vectors/sync-request.bin b/protocols/krikos-identity/tests/vectors/sync-request.bin new file mode 100644 index 00000000000..78fc3d5cc85 --- /dev/null +++ b/protocols/krikos-identity/tests/vectors/sync-request.bin @@ -0,0 +1 @@ +}i}\5Z5Tr MUse _r}Oiku5V\x6}i}\5Z5Tr MUse _r}Oiku5V\x6:wZIf F[G-A:7ȝ3@ \ No newline at end of file diff --git a/protocols/krikos-identity/tests/vectors/sync-response-complete.bin b/protocols/krikos-identity/tests/vectors/sync-response-complete.bin new file mode 100644 index 00000000000..8ddadb2d35f Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/sync-response-complete.bin differ diff --git a/protocols/krikos-identity/tests/vectors/sync-response-frame.bin b/protocols/krikos-identity/tests/vectors/sync-response-frame.bin new file mode 100644 index 00000000000..d1b1d95ad50 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/sync-response-frame.bin differ diff --git a/protocols/krikos-identity/tests/vectors/wrapped-group-key.bin b/protocols/krikos-identity/tests/vectors/wrapped-group-key.bin new file mode 100644 index 00000000000..1ad5b1a1827 Binary files /dev/null and b/protocols/krikos-identity/tests/vectors/wrapped-group-key.bin differ diff --git a/scripts/check-determinism-boundaries.sh b/scripts/check-determinism-boundaries.sh index 56fe6cdb5c6..ef8a0a27c32 100755 --- a/scripts/check-determinism-boundaries.sh +++ b/scripts/check-determinism-boundaries.sh @@ -75,7 +75,16 @@ fi source_roots=() missing_roots=() -for candidate in krikos krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim; do +for candidate in \ + krikos \ + krikos-base \ + krikos-resolver \ + krikos-dns \ + krikos-dns-server \ + krikos-relay \ + krikos-runtime \ + krikos-sim \ + protocols/krikos-identity; do if [[ -d "$repo_root/$candidate" ]]; then source_roots+=("$candidate") else @@ -94,8 +103,7 @@ if [[ ${#source_roots[@]} -eq 0 ]]; then exit 2 fi -# Every root above is a real Cargo package directory (see -# scripts/rename-map.toml, dir_renamed = true); Cargo requires a `[lib]` or +# Every root above is a real Cargo package directory. Cargo requires a `[lib]` or # `[[bin]]` entry point in at least one `.rs` file for such a package to # build, so a root that exists but contains zero `.rs` files is never # legitimate here -- only a symptom of a botched rename (e.g. a `git mv` diff --git a/scripts/check-framework-package-layout.sh b/scripts/check-framework-package-layout.sh index 73f48806923..1b1d18654b2 100755 --- a/scripts/check-framework-package-layout.sh +++ b/scripts/check-framework-package-layout.sh @@ -3,6 +3,7 @@ set -euo pipefail repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +toolchain="${KRIKOS_IDENTITY_TOOLCHAIN:-1.91.0}" scratch=$(mktemp -d) trap 'rm -rf "$scratch"' EXIT @@ -10,12 +11,14 @@ packages=( protocols/krikos-blobs protocols/krikos-gossip protocols/krikos-docs + protocols/krikos-identity framework/app ) for package in "${packages[@]}"; do listing="$scratch/${package//\//-}.txt" - cargo package \ + cargo "+$toolchain" package \ + --locked \ --list \ --allow-dirty \ --manifest-path "$repo_root/$package/Cargo.toml" \ diff --git a/scripts/check-identity-doc-links.py b/scripts/check-identity-doc-links.py new file mode 100755 index 00000000000..9d7890f5db6 --- /dev/null +++ b/scripts/check-identity-doc-links.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Reject broken relative links in the normative krikos-identity Markdown.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +REPOSITORY = Path(__file__).resolve().parents[1] +IDENTITY_ROOT = REPOSITORY / "protocols" / "krikos-identity" +INLINE_LINK = re.compile(r"!?\[[^\]]*\]\((?:<([^>]+)>|([^\s)]+))") +REFERENCE_LINK = re.compile(r"^\s*\[[^\]]+\]:\s*(?:<([^>]+)>|(\S+))", re.MULTILINE) + + +def markdown_without_fenced_code(text: str) -> str: + retained: list[str] = [] + fence: str | None = None + for line in text.splitlines(): + stripped = line.lstrip() + marker = "```" if stripped.startswith("```") else "~~~" if stripped.startswith("~~~") else None + if marker is not None: + fence = None if fence == marker else marker if fence is None else fence + retained.append("") + elif fence is None: + retained.append(line) + else: + retained.append("") + return "\n".join(retained) + + +def relative_target(raw_target: str) -> str | None: + target = raw_target.strip() + if not target or target.startswith("#"): + return None + parsed = urlsplit(target) + if parsed.scheme or parsed.netloc: + return None + return unquote(parsed.path) + + +def main() -> int: + documents = [ + IDENTITY_ROOT / "README.md", + *sorted((IDENTITY_ROOT / "docs").rglob("*.md")), + *sorted((REPOSITORY / "docs" / "identity").rglob("*.md")), + REPOSITORY / "docs" / "README.md", + REPOSITORY / "docs" / "architecture.md", + REPOSITORY / "docs" / "release" / "v2-release-checklist.md", + REPOSITORY / "docs" / "testing" / "simulation.md", + ] + failures: list[str] = [] + checked: set[tuple[Path, str]] = set() + + for document in documents: + text = markdown_without_fenced_code(document.read_text(encoding="utf-8")) + for match in (*INLINE_LINK.finditer(text), *REFERENCE_LINK.finditer(text)): + line = text.count("\n", 0, match.start()) + 1 + raw_target = match.group(1) or match.group(2) + target = relative_target(raw_target) + if target is None: + continue + key = (document, target) + if key in checked: + continue + checked.add(key) + if target.startswith("/"): + failures.append( + f"{document.relative_to(REPOSITORY)}:{line}: absolute local link {raw_target!r}" + ) + continue + resolved = document.parent / target + if not resolved.resolve(strict=False).is_relative_to(REPOSITORY): + failures.append( + f"{document.relative_to(REPOSITORY)}:{line}: local link escapes repository {raw_target!r}" + ) + continue + if not resolved.exists(): + failures.append( + f"{document.relative_to(REPOSITORY)}:{line}: missing relative link {raw_target!r}" + ) + + if len(checked) < 80: + failures.append( + f"identity documentation link inventory unexpectedly small: {len(checked)}" + ) + if failures: + print("\n".join(failures), file=sys.stderr) + return 1 + print(f"identity documentation relative-link inventory passed: {len(checked)} links") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-identity-feature-matrix.sh b/scripts/check-identity-feature-matrix.sh new file mode 100755 index 00000000000..452e5bb7dbe --- /dev/null +++ b/scripts/check-identity-feature-matrix.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repo_root" + +identity_toolchain=${KRIKOS_IDENTITY_TOOLCHAIN:-1.91.0} +static_only=false + +usage() { + printf 'usage: %s [--static-only]\n' "${0##*/}" >&2 +} + +case $# in + 0) ;; + 1) + if [[ "$1" != "--static-only" ]]; then + usage + exit 2 + fi + static_only=true + ;; + *) + usage + exit 2 + ;; +esac + +# Keep the executable matrix coupled to the manifest boundary. This fast pass is +# also used by source-contract tests, where compiling every feature combination +# would duplicate the identity CI gate. +python3 - <<'PY' +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + + +manifest = tomllib.loads( + Path("protocols/krikos-identity/Cargo.toml").read_text(encoding="utf-8") +) +workspace = tomllib.loads(Path("Cargo.toml").read_text(encoding="utf-8")) +failures: list[str] = [] + + +def fail(message: str) -> None: + failures.append(message) + + +if manifest.get("package", {}).get("rust-version", {}).get("workspace") is not True: + fail("krikos-identity must inherit the workspace Rust version") +if workspace.get("workspace", {}).get("package", {}).get("rust-version") != "1.91": + fail("the identity feature matrix is pinned to workspace Rust 1.91") + +expected_features = { + "default": [], + "fs-store": ["dep:redb"], + "net": ["dep:krikos", "dep:tokio", "dep:tokio-util"], + "os-rng": ["dep:getrandom"], + "provider-store": ["dep:redb"], +} +features = manifest.get("features") +if features != expected_features: + fail( + "krikos-identity feature declarations must match the reviewed matrix; " + f"expected {expected_features!r}, found {features!r}" + ) + +dependencies = manifest.get("dependencies", {}) +base = dependencies.get("krikos-base") +if not isinstance(base, dict): + fail("krikos-base must use an explicit dependency table") +else: + if base.get("default-features") is not False: + fail("krikos-base default features must remain disabled") + if base.get("features") != ["key-types"]: + fail("the default identity core must request only krikos-base/key-types") + if base.get("optional") is True: + fail("krikos-base must remain in the no-default normal dependency tree") + +for dependency_name in ("getrandom", "krikos", "redb", "tokio", "tokio-util"): + dependency = dependencies.get(dependency_name) + if not isinstance(dependency, dict) or dependency.get("optional") is not True: + fail(f"{dependency_name} must remain an optional normal dependency") + +if "rand" in dependencies: + fail("krikos-identity must not acquire a direct rand dependency") + +for target_name, target_table in manifest.get("target", {}).items(): + target_dependencies = target_table.get("dependencies", {}) + for dependency_name, dependency in target_dependencies.items(): + package_name = dependency_name + is_optional = False + if isinstance(dependency, dict): + package_name = dependency.get("package", dependency_name) + is_optional = dependency.get("optional") is True + forbidden_target_packages = { + "getrandom", + "krikos", + "rand", + "redb", + "tokio", + "tokio-util", + } + if package_name in forbidden_target_packages and not is_optional: + fail( + f"target {target_name!r} must not add non-optional default dependency " + f"{package_name!r}" + ) + +if failures: + for failure in failures: + print(f"identity feature matrix: {failure}", file=sys.stderr) + raise SystemExit(1) + +print("identity feature matrix static contract passed") +PY + +scripts/tests/check-identity-os-rng-boundary.sh + +if [[ "$static_only" == true ]]; then + exit 0 +fi + +feature_sets=( + 'core|' + 'os-rng|os-rng' + 'fs-store|fs-store' + 'net|net' + 'provider-store|provider-store' + 'fs-store+net|fs-store,net' + 'fs-store+provider-store|fs-store,provider-store' + 'net+provider-store|net,provider-store' + 'fs-store+net+provider-store|fs-store,net,provider-store' +) + +for feature_set in "${feature_sets[@]}"; do + label=${feature_set%%|*} + features=${feature_set#*|} + check_args=( + "+$identity_toolchain" + check + --locked + -p krikos-identity + --lib + --no-default-features + ) + if [[ -n "$features" ]]; then + check_args+=(--features "$features") + fi + printf 'checking krikos-identity feature set: %s\n' "$label" + CARGO_INCREMENTAL=0 cargo "${check_args[@]}" +done + +printf '%s\n' 'checking krikos-identity feature set: all-features' +CARGO_INCREMENTAL=0 cargo \ + "+$identity_toolchain" check --locked -p krikos-identity --lib --all-features + +tree_output=$(mktemp) +trap 'rm -f "$tree_output"' EXIT +cargo "+$identity_toolchain" tree \ + --locked \ + -p krikos-identity \ + --no-default-features \ + --target all \ + --edges normal \ + --prefix none \ + --format '{p}' >"$tree_output" + +python3 - "$tree_output" <<'PY' +from __future__ import annotations + +import sys +from pathlib import Path + + +tree_path = Path(sys.argv[1]) +package_names = { + line.split(maxsplit=1)[0] + for line in tree_path.read_text(encoding="utf-8").splitlines() + if line.strip() +} + +forbidden = {"krikos", "tokio", "redb", "rand", "getrandom"} +present_forbidden = sorted(package_names.intersection(forbidden)) +if present_forbidden: + raise SystemExit( + "no-default identity normal dependency tree contains forbidden packages: " + + ", ".join(present_forbidden) + ) + +if "krikos-base" not in package_names: + raise SystemExit( + "no-default identity normal dependency tree is missing required krikos-base" + ) + +internal_packages = sorted( + name + for name in package_names + if name == "krikos" or name.startswith("krikos-") +) +if internal_packages != ["krikos-base", "krikos-identity"]: + raise SystemExit( + "no-default identity normal dependency tree contains unexpected internal packages: " + + ", ".join(internal_packages) + ) + +print("identity no-default normal dependency isolation passed") +PY + +printf '%s\n' 'identity Rust 1.91 feature matrix passed' diff --git a/scripts/check-identity-interop-vectors.sh b/scripts/check-identity-interop-vectors.sh new file mode 100755 index 00000000000..97b9d79494a --- /dev/null +++ b/scripts/check-identity-interop-vectors.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +toolchain="${KRIKOS_IDENTITY_TOOLCHAIN:-1.91.0}" +generated_root="$(mktemp -d)" +generated_vectors="$generated_root/vectors" +generated_provider_corpus="$generated_root/provider-corpus" +generated_sync_corpus="$generated_root/sync-corpus" +trap 'rm -rf "$generated_root"' EXIT + +cargo "+$toolchain" run \ + --quiet \ + --locked \ + --manifest-path "$repo_root/Cargo.toml" \ + -p krikos-identity \ + --features net \ + --example generate_interop_vectors \ + -- "$generated_vectors" + +diff --recursive --brief \ + "$repo_root/protocols/krikos-identity/tests/vectors" \ + "$generated_vectors" + +python3 "$repo_root/scripts/generate-identity-provider-fuzz-corpus.py" \ + "$generated_vectors" \ + "$generated_provider_corpus" + +for generated_seed in "$generated_provider_corpus"/*.bin; do + cmp \ + "$repo_root/fuzz/corpus/identity_provider/$(basename "$generated_seed")" \ + "$generated_seed" +done + +python3 "$repo_root/scripts/generate-identity-sync-fuzz-corpus.py" \ + "$generated_vectors" \ + "$generated_sync_corpus" + +for generated_seed in "$generated_sync_corpus"/*.bin; do + cmp \ + "$repo_root/fuzz/corpus/identity_sync/$(basename "$generated_seed")" \ + "$generated_seed" +done + +cargo "+$toolchain" test \ + --quiet \ + --locked \ + --manifest-path "$repo_root/Cargo.toml" \ + -p krikos-identity \ + --no-default-features \ + --features net \ + --test interop_vectors \ + --test network_fuzz_corpus \ + --test provider_fuzz_corpus + +printf '%s\n' 'identity interoperability vectors are deterministic and self-validating' diff --git a/scripts/check-identity-model.sh b/scripts/check-identity-model.sh new file mode 100755 index 00000000000..fc0001de17e --- /dev/null +++ b/scripts/check-identity-model.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +spec="$repo_root/docs/identity/AccountControl.tla" + +for marker in \ + 'Init ==' \ + 'Next ==' \ + 'WeightOf(controllerSet) ==' \ + 'RevokedControllersCannotAuthorize ==' \ + 'PolicyChangesUsePreviousPolicy ==' \ + 'ForksAreDetectable ==' \ + 'ThresholdRequirementsPreserved ==' \ + 'RecoveryDoesNotRetainOldControllers ==' \ + 'AcceptedEventsHaveUniquePredecessor ==' +do + rg --fixed-strings --quiet "$marker" "$spec" +done + +report=$(cargo +1.91.0 run \ + --quiet \ + --locked \ + --manifest-path "$repo_root/krikos-sim/Cargo.toml" \ + --bin identity-model-check) + +printf '%s\n' "$report" +printf '%s\n' "$report" | rg --quiet '"states_explored": [1-9][0-9]*' +printf '%s\n' "$report" | rg --quiet '"transitions_explored": [1-9][0-9]*' +printf '%s\n' "$report" | rg --quiet '"tla_actions_validated": 6' +printf '%s\n' "$report" | rg --quiet '"tla_properties_validated": 6' +printf '%s\n' "$report" | rg --quiet '"semantic_parity_cases": [1-9][0-9]*' +printf '%s\n' "$report" | rg --quiet '"asymmetric_weight_witnesses": [1-9][0-9]*' +printf '%s\n' "$report" | rg --quiet '"transition_mutations_rejected": 7' +printf '%s\n' "$report" | rg --quiet '"portable_mutations_rejected": 2' + +for property in \ + revoked_controllers_cannot_authorize \ + policy_changes_use_previous_policy \ + forks_are_detectable \ + threshold_requirements_preserved \ + recovery_does_not_retain_old_controllers \ + accepted_events_have_unique_predecessor +do + printf '%s\n' "$report" | rg --quiet "\"$property\": [1-9][0-9]*" +done + +for witness in \ + evaluations \ + antecedent_witnesses \ + accepted_witnesses \ + rejected_witnesses +do + count=$(printf '%s\n' "$report" | rg --count "\"$witness\": [1-9][0-9]*") + test "$count" -eq 6 +done diff --git a/scripts/check-identity-release-gate.py b/scripts/check-identity-release-gate.py new file mode 100755 index 00000000000..7e2a2941c2f --- /dev/null +++ b/scripts/check-identity-release-gate.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Fail closed until every krikos-identity stable-release criterion is approved.""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +PACKAGE_NAME = "krikos-identity" +PACKAGE_PATH = "protocols/krikos-identity" +EXPECTED_PACKAGE = {"name": PACKAGE_NAME, "path": PACKAGE_PATH} +RELEASE_DEPENDENCIES = ("krikos-base", "krikos") +APPROVAL_MESSAGES = { + "third_party_security_audit": "third-party security audit is not approved", + "independently_maintained_interoperability": ( + "independently maintained interoperability is not approved" + ), + "production_provider_diversity": "production provider diversity is not approved", + "protocol_governance": "protocol governance is not approved", + "public_api_semver_baseline": "public API and SemVer baseline is not approved", + "persistent_schema_support": "persistent-schema support is not approved", +} + + +def load_toml(path: Path) -> dict: + with path.open("rb") as source: + return tomllib.load(source) + + +def validate_evidence(policy: dict, approvals: object, failures: list[str]) -> None: + evidence = policy.get("evidence") + if not isinstance(evidence, dict) or set(evidence) != set(APPROVAL_MESSAGES): + failures.append("evidence must contain exactly the reviewed approval names") + return + + for name, references in evidence.items(): + if not isinstance(references, list) or any( + not isinstance(reference, str) or not reference.strip() + for reference in references + ): + failures.append(f"evidence for {name} must be a list of non-empty strings") + + if not isinstance(approvals, dict): + return + for name, approved in approvals.items(): + if approved is True and not evidence.get(name): + failures.append(f"approved criterion {name} has no recorded evidence") + + +def release_package_order(repo_root: Path, failures: list[str]) -> list[str]: + release_script = (repo_root / "scripts/verify-release-packages.sh").read_text( + encoding="utf-8" + ) + match = re.search(r'^packages="([^"]*)"$', release_script, re.MULTILINE) + if match is None: + failures.append("the publishable release package order could not be parsed") + return [] + packages = match.group(1).split() + if len(packages) != len(set(packages)): + failures.append("the publishable release package order contains duplicates") + return packages + + +def validate(repo_root: Path) -> tuple[dict, list[str]]: + policy = load_toml(repo_root / PACKAGE_PATH / "release-gate.toml") + failures: list[str] = [] + + if policy.get("schema_version") != 1: + failures.append("release gate schema_version must be 1") + if policy.get("package") != EXPECTED_PACKAGE: + failures.append("release gate package identity differs from krikos-identity") + + status = policy.get("status") + if status not in {"blocked", "open"}: + failures.append("release gate status must be blocked or open") + + approvals = policy.get("approvals") + if not isinstance(approvals, dict) or set(approvals) != set(APPROVAL_MESSAGES): + failures.append("approvals must contain exactly the reviewed stable-release criteria") + elif any(not isinstance(value, bool) for value in approvals.values()): + failures.append("approval values must be booleans") + validate_evidence(policy, approvals, failures) + + root_manifest = load_toml(repo_root / "Cargo.toml") + members = root_manifest.get("workspace", {}).get("members", []) + if PACKAGE_PATH not in members: + failures.append(f"{PACKAGE_PATH} is not a root workspace member") + + package_manifest = load_toml(repo_root / PACKAGE_PATH / "Cargo.toml").get( + "package", {} + ) + if package_manifest.get("name") != PACKAGE_NAME: + failures.append(f"{PACKAGE_PATH} package name is not {PACKAGE_NAME}") + if package_manifest.get("version") != {"workspace": True}: + failures.append(f"{PACKAGE_NAME} must use the coordinated workspace version") + + make_policy = load_toml(repo_root / "Makefile.toml") + skip_members = make_policy.get("env", {}).get( + "CARGO_MAKE_WORKSPACE_SKIP_MEMBERS", [] + ) + if not isinstance(skip_members, list) or any( + not isinstance(member, str) for member in skip_members + ): + failures.append("the external-types skip list must be a string list") + skip_members = [] + + release_packages = release_package_order(repo_root, failures) + release_occurrences = release_packages.count(PACKAGE_NAME) + + if status == "blocked": + if package_manifest.get("publish") is not False: + failures.append( + f"{PACKAGE_NAME} must retain publish = false while the gate is closed" + ) + if PACKAGE_PATH not in skip_members: + failures.append( + f"{PACKAGE_PATH} must remain outside the external-types baseline " + "while the gate is closed" + ) + if release_occurrences: + failures.append( + f"{PACKAGE_NAME} entered the publishable release order while its gate is closed" + ) + elif status == "open": + if ( + "publish" in package_manifest + and package_manifest.get("publish") is not True + ): + failures.append( + f"{PACKAGE_NAME} publish setting is not open for the stable registry" + ) + if PACKAGE_PATH in skip_members: + failures.append( + f"{PACKAGE_PATH} remains outside the external-types baseline " + "while its gate is open" + ) + if release_occurrences != 1: + failures.append( + f"{PACKAGE_NAME} must occur exactly once in the publishable release order " + "while its gate is open" + ) + else: + package_index = release_packages.index(PACKAGE_NAME) + for dependency in RELEASE_DEPENDENCIES: + if release_packages.count(dependency) != 1: + failures.append( + f"{dependency} must occur exactly once before {PACKAGE_NAME} " + "in the publishable release order" + ) + elif release_packages.index(dependency) > package_index: + failures.append( + f"{PACKAGE_NAME} must follow {dependency} in the publishable " + "release order" + ) + + return policy, failures + + +def main() -> int: + parser = argparse.ArgumentParser() + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--expect-closed", action="store_true") + mode.add_argument("--require-open", action="store_true") + parser.add_argument("--repo-root", type=Path, help=argparse.SUPPRESS) + args = parser.parse_args() + repo_root = ( + args.repo_root.resolve() + if args.repo_root is not None + else Path(__file__).resolve().parent.parent + ) + + try: + policy, failures = validate(repo_root) + except (OSError, tomllib.TOMLDecodeError) as error: + print(f"identity release gate: cannot read policy inputs: {error}", file=sys.stderr) + return 1 + + if failures: + for failure in failures: + print(f"identity release gate: {failure}", file=sys.stderr) + return 1 + + approvals = policy.get("approvals", {}) + blockers = [ + message + for name, message in APPROVAL_MESSAGES.items() + if approvals.get(name) is not True + ] + status = policy.get("status") + + if args.expect_closed: + if status != "blocked" or not blockers: + print( + "identity release gate: closed-gate policy is inconsistent", + file=sys.stderr, + ) + return 1 + print("krikos-identity stable-release gate is closed as required") + return 0 + + if status != "open" or blockers: + for blocker in blockers: + print(f"identity release gate: {blocker}", file=sys.stderr) + if status != "open": + print("identity release gate: status is not open", file=sys.stderr) + return 1 + + print("krikos-identity stable-release gate is open") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-identity-wire-inventory.sh b/scripts/check-identity-wire-inventory.sh new file mode 100755 index 00000000000..890794387a3 --- /dev/null +++ b/scripts/check-identity-wire-inventory.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +toolchain="${KRIKOS_IDENTITY_TOOLCHAIN:-1.91.0}" +doc_target="${KRIKOS_IDENTITY_DOC_TARGET:-$repo_root/target/identity-wire-inventory}" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +CARGO_TARGET_DIR="$doc_target" cargo "+$toolchain" doc \ + --quiet \ + --locked \ + --manifest-path "$repo_root/Cargo.toml" \ + -p krikos-identity \ + --all-features \ + --no-deps \ + --document-private-items + +implementers="$doc_target/doc/krikos_identity/codec/sealed/trait.CanonicalCodec.html" +[[ -f "$implementers" ]] || { + printf 'missing rustdoc CanonicalCodec implementer inventory: %s\n' "$implementers" >&2 + exit 1 +} + +grep -o 'id="impl-CanonicalCodec-for-[^"]*"' "$implementers" \ + | sed 's/id="impl-CanonicalCodec-for-//; s/"$//' \ + | sort -u > "$scratch/implemented" + +{ + rg --no-filename -o 'round_trip::<[A-Za-z0-9_]+' \ + "$repo_root"/fuzz/fuzz_targets/identity_*.rs \ + | sed 's/round_trip:: "$scratch/covered" + +comm -23 "$scratch/implemented" "$scratch/covered" > "$scratch/missing" +comm -13 "$scratch/implemented" "$scratch/covered" > "$scratch/stale" + +if [[ -s "$scratch/missing" || -s "$scratch/stale" ]]; then + if [[ -s "$scratch/missing" ]]; then + printf '%s\n' 'public canonical decoders missing from identity target inventories:' >&2 + sed 's/^/ /' "$scratch/missing" >&2 + fi + if [[ -s "$scratch/stale" ]]; then + printf '%s\n' 'stale identity target decoder names without a canonical implementation:' >&2 + sed 's/^/ /' "$scratch/stale" >&2 + fi + exit 1 +fi + +implemented_count="$(wc -l < "$scratch/implemented")" +covered_count="$(wc -l < "$scratch/covered")" +[[ "$implemented_count" -eq "$covered_count" ]] || { + printf 'canonical decoder count mismatch: implemented=%s covered=%s\n' \ + "$implemented_count" "$covered_count" >&2 + exit 1 +} + +printf 'identity canonical decoder inventory is complete: %s types\n' "$implemented_count" diff --git a/scripts/determinism-boundaries.semantic.txt b/scripts/determinism-boundaries.semantic.txt index ed91ba2c981..dcab835c6e3 100644 --- a/scripts/determinism-boundaries.semantic.txt +++ b/scripts/determinism-boundaries.semantic.txt @@ -201,6 +201,7 @@ clock-timer krikos/examples/transfer.rs impl::bind_endpoint tokio: clock-timer krikos/examples/transfer.rs impl::new std::time::Instant::now 1 clock-timer krikos/examples/transfer.rs send_data std::time::Instant::now 1 clock-timer krikos/examples/transfer.rs write_chunk_timeout tokio::time::sleep 1 +clock-timer krikos/examples/transfer.rs write_chunk_timeout tokio::time::timeout 1 clock-timer krikos/src/address_lookup.rs tests::concurrent_connects_do_not_race_on_empty_resolve time::sleep 1 clock-timer krikos/src/address_lookup.rs tests::concurrent_connects_not_blocked_by_slow_lookup timeout 1 clock-timer krikos/src/address_lookup.rs tests::concurrent_connects_not_blocked_by_slow_lookup timeout 2 @@ -348,11 +349,16 @@ clock-timer krikos/tests/patchbay/degrade.rs run_degrade_level tokio::time::time clock-timer krikos/tests/patchbay/util.rs impl::wait_selected tokio::time::timeout 1 clock-timer krikos/tests/patchbay/util.rs ping_accept tokio::time::timeout 1 clock-timer krikos/tests/patchbay/util.rs ping_open tokio::time::timeout 1 -entropy-random krikos-base/src/endpoint_addr.rs tests::endpoint_addr_deserialization_rejects_excessive_address_count crate::SecretKey::generate 1 -entropy-random krikos-base/src/endpoint_addr.rs tests::fallible_constructors_enforce_all_default_address_limits crate::SecretKey::generate 1 -entropy-random krikos-base/src/endpoint_addr.rs tests::public_field_mutation_is_detected_by_validation crate::SecretKey::generate 1 +clock-timer protocols/krikos-identity/src/net/mod.rs impl::shutdown tokio::time::timeout 1 +clock-timer protocols/krikos-identity/tests/net_contracts.rs pairing_proposal_and_sync_use_repository_local_relay_only_addresses tokio::time::timeout 1 +clock-timer protocols/krikos-identity/tests/net_contracts.rs pairing_proposal_and_sync_use_repository_local_relay_only_addresses tokio::time::timeout 2 +clock-timer protocols/krikos-identity/tests/net_contracts.rs router_shutdown_cancels_pending_read_and_pending_service_without_detaching tokio::time::timeout 1 +clock-timer protocols/krikos-identity/tests/net_contracts.rs router_shutdown_cancels_pending_read_and_pending_service_without_detaching tokio::time::timeout 2 +clock-timer protocols/krikos-identity/tests/net_contracts.rs router_shutdown_cancels_pending_read_and_pending_service_without_detaching tokio::time::timeout 3 +clock-timer protocols/krikos-identity/tests/net_contracts.rs shared_handler_admission_caps_aggregate_network_service_concurrency tokio::time::sleep 1 +clock-timer protocols/krikos-identity/tests/net_contracts.rs shared_handler_admission_caps_aggregate_network_service_concurrency tokio::time::timeout 1 +clock-timer protocols/krikos-identity/tests/net_contracts.rs shared_handler_admission_caps_aggregate_network_service_concurrency tokio::time::timeout 2 entropy-random krikos-base/src/key.rs impl::generate rand::random 1 -entropy-random krikos-base/src/key.rs tests::signature_postcard SecretKey::generate 1 entropy-random krikos-dns-server/examples/publish.rs main krikos::SecretKey::generate 1 entropy-random krikos-dns-server/src/store/signed_packets.rs tests::test_signed_packet SecretKey::generate 1 entropy-random krikos-dns-server/tests/publish_resolve.rs pkarr_publish_dns_resolve krikos::SecretKey::generate 1 @@ -372,6 +378,7 @@ entropy-random krikos/examples/listen-unreliable.rs main krikos::SecretKey::gene entropy-random krikos/examples/listen.rs main krikos::SecretKey::generate 1 entropy-random krikos/examples/transfer.rs main krikos::SecretKey::generate 1 entropy-random krikos/src/address_lookup.rs tests::impl::resolve rand::rng 1 +entropy-random krikos/src/endpoint/builder.rs impl::bind SecretKey::generate 1 entropy-random krikos/src/endpoint/builder.rs impl::bind rand::rng 1 entropy-random krikos/src/endpoint/builder.rs impl::bind rand::rng 2 entropy-random krikos/src/net_report/reportgen.rs check_captive_portal rand::rng 1 @@ -391,6 +398,20 @@ entropy-random krikos/src/test_utils/test_transport.rs tests::test_custom_transp entropy-random krikos/src/test_utils/test_transport.rs tests::test_ip_wins_over_custom SecretKey::generate 1 entropy-random krikos/src/test_utils/test_transport.rs tests::test_ip_wins_over_custom SecretKey::generate 2 entropy-random krikos/tests/patchbay/util.rs ping_open rand::random 1 +entropy-random protocols/krikos-identity/src/key_wrap.rs rotate_group_key getrandom::fill 1 +entropy-random protocols/krikos-identity/src/pairing.rs impl::generate getrandom::fill 1 +entropy-random protocols/krikos-identity/src/pairing.rs impl::issue getrandom::fill 1 +entropy-random protocols/krikos-identity/src/pairing.rs impl::issue getrandom::fill 2 +entropy-random protocols/krikos-identity/src/pairing.rs impl::issue getrandom::fill 3 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 1 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 2 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 3 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 4 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 1 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 2 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 3 +entropy-random protocols/krikos-identity/src/privacy.rs impl::seal getrandom::fill 4 +entropy-random protocols/krikos-identity/src/privacy.rs os_secret getrandom::fill 1 external-state krikos-dns-server/examples/publish.rs main std::env::var 1 external-state krikos-dns-server/src/config.rs impl::load tokio::fs::File::open 1 external-state krikos-dns-server/src/http/tls.rs read_tls_file tokio::fs::File::open 1 @@ -453,6 +474,21 @@ external-state krikos-sim/tests/cli.rs versioned_declarative_run_and_expected_fa external-state krikos-sim/tests/cli.rs versioned_declarative_run_and_expected_failure_replay_through_the_same_cli std::process::Command::new 6 external-state krikos-sim/tests/cli.rs versioned_declarative_run_and_expected_failure_replay_through_the_same_cli std::process::Command::new 7 external-state krikos-sim/tests/cli.rs versioned_declarative_run_and_expected_failure_replay_through_the_same_cli std::process::Command::new 8 +external-state krikos-sim/tests/identity.rs identity_cli_checks_the_reviewed_corpus_and_formal_model std::process::Command::new 1 +external-state krikos-sim/tests/identity.rs identity_cli_checks_the_reviewed_corpus_and_formal_model std::process::Command::new 2 +external-state krikos-sim/tests/identity.rs identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review std::process::Command::new 1 +external-state krikos-sim/tests/identity.rs identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review std::process::Command::new 2 +external-state krikos-sim/tests/identity.rs identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review std::process::Command::new 3 +external-state krikos-sim/tests/identity.rs identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review std::process::Command::new 4 +external-state krikos-sim/tests/identity.rs identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review std::process::Command::new 5 +external-state krikos-sim/tests/identity.rs identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review std::process::Command::new 6 +external-state krikos-sim/tests/identity.rs identity_cli_confirms_minimizes_replays_and_stages_a_real_failure_for_review std::process::Command::new 7 +external-state krikos-sim/tests/identity.rs identity_cli_replays_expected_model_rejection_without_product_failure_artifacts std::process::Command::new 1 +external-state krikos-sim/tests/identity.rs identity_cli_replays_expected_model_rejection_without_product_failure_artifacts std::process::Command::new 2 +external-state krikos-sim/tests/identity.rs identity_cli_run_artifacts_replay_report_and_traces_exactly std::process::Command::new 1 +external-state krikos-sim/tests/identity.rs identity_cli_run_artifacts_replay_report_and_traces_exactly std::process::Command::new 2 +external-state krikos-sim/tests/identity.rs identity_rejection_replay_rejects_noncanonical_report_bytes std::process::Command::new 1 +external-state krikos-sim/tests/identity.rs identity_rejection_replay_rejects_noncanonical_report_bytes std::process::Command::new 2 external-state krikos/bench/build.rs main std::env::var 1 external-state krikos/bench/build.rs main std::env::var 2 external-state krikos/bench/src/bin/bulk.rs run_krikos std::thread::spawn 1 @@ -475,6 +511,10 @@ external-state krikos/src/endpoint.rs proxy_url_from_env std::env::var 3 external-state krikos/src/endpoint.rs proxy_url_from_env std::env::var 4 external-state krikos/src/test_utils/qlog.rs impl::create std::env::var 1 external-state krikos/tests/patchbay/nat.rs write_public_parity_receipt std::fs::OpenOptions::new 1 +external-state protocols/krikos-identity/examples/provider_auditor.rs read_wire std::fs::File::open 1 +external-state protocols/krikos-identity/src/redb_guard.rs validate_existing_redb_file std::fs::File::open 1 +external-state protocols/krikos-identity/tests/provider_persistence.rs concurrent_redb_appends_are_linearizable_and_duplicate_idempotent std::thread::spawn 1 +external-state protocols/krikos-identity/tests/provider_persistence.rs concurrent_redb_appends_are_linearizable_and_duplicate_idempotent std::thread::spawn 2 network-environment krikos-dns-server/examples/resolve.rs main tokio::net::lookup_host 1 network-environment krikos-dns-server/src/dns.rs impl::spawn tokio::net::TcpListener::bind 1 network-environment krikos-dns-server/src/dns.rs impl::spawn tokio::net::UdpSocket::bind 1 @@ -648,6 +688,9 @@ spawn-task krikos/src/test_utils.rs pkarr_relay::run_pkarr_relay tokio::spawn 1 spawn-task krikos/src/test_utils/test_transport.rs tests::test_custom_transport_local_addr tokio::spawn 1 spawn-task krikos/tests/integration.rs simple_endpoint_id_based_connection_transfer n0_future::task::spawn 1 spawn-task krikos/tests/patchbay/util.rs watch_selected_path tokio::spawn 1 +spawn-task protocols/krikos-identity/src/net/mod.rs tokio::task::JoinSet 1 +spawn-task protocols/krikos-identity/tests/provider_persistence.rs concurrent_redb_appends_are_linearizable_and_duplicate_idempotent std::thread::spawn 1 +spawn-task protocols/krikos-identity/tests/provider_persistence.rs concurrent_redb_appends_are_linearizable_and_duplicate_idempotent std::thread::spawn 2 unordered-collection krikos-dns/src/endpoint_info.rs dedup std::collections::HashSet 1 unordered-collection krikos-relay/src/server/client.rs std::collections::HashSet 1 unordered-collection krikos-relay/src/server/clients.rs dashmap::DashMap 1 diff --git a/scripts/determinism-boundaries.txt b/scripts/determinism-boundaries.txt index 37022db73f8..1a61a78c5e9 100644 --- a/scripts/determinism-boundaries.txt +++ b/scripts/determinism-boundaries.txt @@ -219,11 +219,11 @@ clock-timer krikos/src/address_lookup/memory.rs:185 let last_updated = SystemTim clock-timer krikos/src/address_lookup/pkarr.rs:735 Instant::now() clock-timer krikos/src/address_lookup/pkarr.rs:761 assert!(publish_deadline(Duration::MAX) > Instant::now()); clock-timer krikos/src/defaults.rs:124 use n0_future::time::Duration; -clock-timer krikos/src/endpoint/connection.rs:1723 tokio::time::timeout(Duration::from_secs(1), wait_for_paths(&mut paths_server)) -clock-timer krikos/src/endpoint/connection.rs:1729 tokio::time::timeout(Duration::from_secs(1), wait_for_paths(&mut paths_client)) -clock-timer krikos/src/endpoint/connection.rs:1736 tokio::time::pause(); -clock-timer krikos/src/endpoint/connection.rs:1743 tokio::time::timeout(Duration::from_nanos(1), async { -clock-timer krikos/src/endpoint/connection.rs:1748 tokio::time::timeout(Duration::from_nanos(1), async { +clock-timer krikos/src/endpoint/connection.rs:1737 tokio::time::timeout(Duration::from_secs(1), wait_for_paths(&mut paths_server)) +clock-timer krikos/src/endpoint/connection.rs:1743 tokio::time::timeout(Duration::from_secs(1), wait_for_paths(&mut paths_client)) +clock-timer krikos/src/endpoint/connection.rs:1750 tokio::time::pause(); +clock-timer krikos/src/endpoint/connection.rs:1757 tokio::time::timeout(Duration::from_nanos(1), async { +clock-timer krikos/src/endpoint/connection.rs:1762 tokio::time::timeout(Duration::from_nanos(1), async { clock-timer krikos/src/endpoint/quic.rs:711 /// Defaults to [`noq::StdSystemTime`], which simply calls [`SystemTime::now()`](std::time::SystemTime::now). clock-timer krikos/src/endpoint/tests.rs:1047 let mut addr = tokio::time::timeout(Duration::from_secs(10), async move { clock-timer krikos/src/endpoint/tests.rs:1245 let _conn = tokio::time::timeout( @@ -351,12 +351,18 @@ clock-timer krikos/tests/patchbay/degrade.rs:117 let result = tokio::time::timeo clock-timer krikos/tests/patchbay/util.rs:368 tokio::time::timeout(timeout, async { clock-timer krikos/tests/patchbay/util.rs:396 tokio::time::timeout(timeout, async { clock-timer krikos/tests/patchbay/util.rs:416 tokio::time::timeout(timeout, async { -entropy-random krikos-base/src/endpoint_addr.rs:870 let key = crate::SecretKey::generate().public(); -entropy-random krikos-base/src/endpoint_addr.rs:884 let key = crate::SecretKey::generate().public(); -entropy-random krikos-base/src/endpoint_addr.rs:904 let key = crate::SecretKey::generate().public(); +clock-timer protocols/krikos-identity/src/net/mod.rs:297 match tokio::time::timeout(SHUTDOWN_TIMEOUT, drain).await { +clock-timer protocols/krikos-identity/tests/net_contracts.rs:1074 tokio::time::timeout( +clock-timer protocols/krikos-identity/tests/net_contracts.rs:1080 tokio::time::timeout(std::time::Duration::from_secs(2), router.shutdown()) +clock-timer protocols/krikos-identity/tests/net_contracts.rs:1108 tokio::time::timeout(std::time::Duration::from_secs(2), router.shutdown()) +clock-timer protocols/krikos-identity/tests/net_contracts.rs:1332 tokio::time::timeout(std::time::Duration::from_secs(10), async { +clock-timer protocols/krikos-identity/tests/net_contracts.rs:1341 tokio::time::sleep(std::time::Duration::from_millis(50)).await; +clock-timer protocols/krikos-identity/tests/net_contracts.rs:1352 tokio::time::timeout(std::time::Duration::from_secs(10), async { +clock-timer protocols/krikos-identity/tests/net_contracts.rs:933 tokio::time::timeout(std::time::Duration::from_secs(10), controller.online()) +clock-timer protocols/krikos-identity/tests/net_contracts.rs:936 tokio::time::timeout(std::time::Duration::from_secs(10), proposed.online()) entropy-random krikos-base/src/key.rs:314 /// let mut rng = rand::rng(); -entropy-random krikos-base/src/key.rs:319 Self::from_bytes(&rand::random()) -entropy-random krikos-base/src/key.rs:558 let key = SecretKey::generate(); +entropy-random krikos-base/src/key.rs:321 Self::from_bytes(&rand::random()) +entropy-random krikos-base/src/lib.rs:6 //! - `os-rng` adds the `SecretKey::generate` operating-system entropy convenience API. entropy-random krikos-dns-server/examples/publish.rs:69 let s = SecretKey::generate(); entropy-random krikos-dns-server/src/store/signed_packets.rs:865 let secret_key = SecretKey::generate(); entropy-random krikos-dns-server/tests/publish_resolve.rs:28 let secret_key = SecretKey::generate(); @@ -398,6 +404,20 @@ entropy-random krikos/src/test_utils/test_transport.rs:591 let s2 = SecretKey::g entropy-random krikos/src/test_utils/test_transport.rs:634 let s1 = SecretKey::generate(); entropy-random krikos/src/test_utils/test_transport.rs:635 let s2 = SecretKey::generate(); entropy-random krikos/tests/patchbay/util.rs:397 let data: [u8; 8] = rand::random(); +entropy-random protocols/krikos-identity/src/key_wrap.rs:946 let (ephemeral_secret, nonce) = generate_wrap_randomness_with(getrandom::fill)?; +entropy-random protocols/krikos-identity/src/pairing.rs:206 getrandom::fill(&mut random_secret[..]).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/pairing.rs:207 getrandom::fill(&mut ephemeral_secret[..]) +entropy-random protocols/krikos-identity/src/pairing.rs:209 getrandom::fill(&mut nonce).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/pairing.rs:702 getrandom::fill(&mut bytes[..]).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:2065 getrandom::fill(&mut salt).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:2066 getrandom::fill(&mut wrapping_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:2067 getrandom::fill(&mut content_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:2068 getrandom::fill(content_key.as_mut()).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:2424 getrandom::fill(&mut bytes).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:254 getrandom::fill(&mut salt).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:255 getrandom::fill(&mut wrapping_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:256 getrandom::fill(&mut content_nonce).map_err(|_| IdentityError::EntropyUnavailable)?; +entropy-random protocols/krikos-identity/src/privacy.rs:257 getrandom::fill(content_key.as_mut()).map_err(|_| IdentityError::EntropyUnavailable)?; external-state krikos-dns-server/examples/publish.rs:65 let secret_key = match std::env::var("KRIKOS_SECRET") { external-state krikos-dns-server/src/config.rs:590 let file = tokio::fs::File::open(path) external-state krikos-dns-server/src/config.rs:624 } else if let Some(val) = env::var_os("KRIKOS_DNS_DATA_DIR") { @@ -427,6 +447,8 @@ external-state krikos-sim/src/bin/cargo-sim.rs:4 let args: Vec<_> = std::env::ar external-state krikos-sim/src/bounded_io.rs:19 let file = File::open(path)?; external-state krikos-sim/src/cli/campaign.rs:363 let journal = OpenOptions::new() external-state krikos-sim/src/cli/campaign.rs:427 let mut file = OpenOptions::new() +external-state krikos-sim/src/cli/identity.rs:209 let manifest_path = std::fs::canonicalize(absolutize(manifest_path)?).map_err(CliError::Io)?; +external-state krikos-sim/src/cli/identity.rs:292 let manifest_path = std::fs::canonicalize(absolutize(manifest_path)?).map_err(CliError::Io)?; external-state krikos-sim/src/cli/mod.rs:6 fs::{self, OpenOptions}, external-state krikos-sim/src/cli/parity.rs:126 let mut file = OpenOptions::new().create_new(true).write(true).open(path)?; external-state krikos-sim/src/cli/shared.rs:258 let output = ProcessCommand::new("git") @@ -484,6 +506,53 @@ external-state krikos-sim/tests/cli.rs:974 let campaign = Command::new(env!("CAR external-state krikos-sim/tests/corpus_campaign.rs:285 let path = std::env::temp_dir().join(format!("krikos-sim-{label}-{}", std::process::id())); external-state krikos-sim/tests/coverage.rs:612 std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative)) external-state krikos-sim/tests/failure_replay.rs:210 std::env::temp_dir().join(format!("krikos-sim-failure-{label}-{}", std::process::id())); +external-state krikos-sim/tests/identity.rs:1298 for entry in std::fs::read_dir(source).unwrap() { +external-state krikos-sim/tests/identity.rs:1303 let text = std::fs::read_to_string(entry.path()).unwrap(); +external-state krikos-sim/tests/identity.rs:1338 let corpus_output = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1352 let formal_output = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1373 let run = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1386 let replay = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1422 std::fs::write(&scenario_path, scenario.to_canonical_json().unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1545 let run = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1571 let replay = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1607 std::fs::write(&scenario_path, scenario.to_canonical_json().unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1609 let run = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1623 let mut noncanonical = std::fs::read(&report_path).unwrap(); +external-state krikos-sim/tests/identity.rs:1625 std::fs::write(&report_path, noncanonical).unwrap(); +external-state krikos-sim/tests/identity.rs:1627 let replay = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1668 std::fs::write(&scenario_path, scenario.to_canonical_json().unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1670 let run = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1701 IdentityScenario::from_json(&std::fs::read(artifacts.join("scenario.json")).unwrap()) +external-state krikos-sim/tests/identity.rs:1706 let replay = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1718 let promote = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1732 serde_json::from_slice(&std::fs::read(candidate.join("entry.json")).unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1755 std::fs::create_dir(&tampered).unwrap(); +external-state krikos-sim/tests/identity.rs:1756 for artifact in std::fs::read_dir(&artifacts).unwrap() { +external-state krikos-sim/tests/identity.rs:1758 std::fs::copy(artifact.path(), tampered.join(artifact.file_name())).unwrap(); +external-state krikos-sim/tests/identity.rs:1761 let mut bytes = std::fs::read(&target_path).unwrap(); +external-state krikos-sim/tests/identity.rs:1763 std::fs::write(&target_path, bytes).unwrap(); +external-state krikos-sim/tests/identity.rs:1764 let rejected = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1774 std::fs::write( +external-state krikos-sim/tests/identity.rs:1780 let rejected = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1794 serde_json::from_slice(&std::fs::read(&confirmation_path).unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1798 std::fs::write(&confirmation_path, confirmation_bytes).unwrap(); +external-state krikos-sim/tests/identity.rs:1800 let rejected = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1814 serde_json::from_slice(&std::fs::read(&minimization_path).unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1818 std::fs::write(&minimization_path, minimization_bytes).unwrap(); +external-state krikos-sim/tests/identity.rs:1820 let rejected = std::process::Command::new(binary) +external-state krikos-sim/tests/identity.rs:1831 std::fs::create_dir(&promoted_corpus).unwrap(); +external-state krikos-sim/tests/identity.rs:1834 std::fs::copy(checked_in.join(name), promoted_corpus.join(name)).unwrap(); +external-state krikos-sim/tests/identity.rs:1836 std::fs::copy( +external-state krikos-sim/tests/identity.rs:1842 serde_json::from_slice(&std::fs::read(checked_in.join("manifest.json")).unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1849 std::fs::write( +external-state krikos-sim/tests/identity.rs:1857 std::fs::write( +external-state krikos-sim/tests/identity.rs:1868 std::fs::write( +external-state krikos-sim/tests/identity.rs:1877 std::fs::create_dir(target).unwrap(); +external-state krikos-sim/tests/identity.rs:1878 for artifact in std::fs::read_dir(source).unwrap() { +external-state krikos-sim/tests/identity.rs:1880 std::fs::copy(artifact.path(), target.join(artifact.file_name())).unwrap(); +external-state krikos-sim/tests/identity.rs:1887 serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); +external-state krikos-sim/tests/identity.rs:1888 let bytes = std::fs::read(root.join(name)).unwrap(); +external-state krikos-sim/tests/identity.rs:1892 std::fs::write(index_path, index_bytes).unwrap(); external-state krikos-sim/tests/trace.rs:184 let path = std::env::temp_dir().join(format!( external-state krikos/bench/build.rs:3 use std::env; external-state krikos/bench/build.rs:6 let profile = env::var("PROFILE").expect("Cargo always sets PROFILE for build scripts"); @@ -517,6 +586,10 @@ external-state krikos/src/test_utils/qlog.rs:77 if std::env::var("KRIKOS_TEST_QL external-state krikos/tests/patchbay/nat.rs:14 use std::{fs::OpenOptions, io::Write, time::Duration}; external-state krikos/tests/patchbay/nat.rs:141 let Some(output) = std::env::var_os(OUTPUT_ENV) else { external-state krikos/tests/patchbay/nat.rs:170 let mut file = OpenOptions::new() +external-state protocols/krikos-identity/examples/provider_auditor.rs:20 fs::File::open(path)? +external-state protocols/krikos-identity/src/redb_guard.rs:40 File::open(path) +external-state protocols/krikos-identity/tests/provider_persistence.rs:1768 threads.push(thread::spawn(move || { +external-state protocols/krikos-identity/tests/provider_persistence.rs:1813 duplicate_threads.push(thread::spawn(move || { network-environment krikos-dns-server/examples/resolve.rs:51 let addr = tokio::net::lookup_host(host) network-environment krikos-dns-server/examples/resolve.rs:77 resolver.lookup_by_id(&endpoint_id, origin_domain).await? network-environment krikos-dns-server/examples/resolve.rs:79 Command::Domain { domain } => resolver.lookup_by_domain_name(&domain).await?, @@ -826,6 +899,7 @@ network-environment krikos/src/test_utils.rs:230 let socket = UdpSocket::bind(bi network-environment krikos/src/test_utils.rs:251 socket: UdpSocket, network-environment krikos/src/test_utils.rs:312 let listener = tokio::net::TcpListener::bind(bind_addr).await?; network-environment krikos/src/util.rs:42 let res = this.lookup_ipv4_ipv6(name, DNS_TIMEOUT).await; +network-environment protocols/krikos-identity/tests/privacy_boundaries.rs:77 fn lookup_handles_rotate_and_bind_provider_account_and_generation() { spawn-task krikos-dns-server/src/http.rs:113 let mut tasks = JoinSet::new(); spawn-task krikos-dns-server/src/http.rs:24 use tokio::{net::TcpListener, task::JoinSet}; spawn-task krikos-dns-server/src/http.rs:85 tasks: JoinSet>, @@ -924,7 +998,7 @@ spawn-task krikos/src/address_lookup.rs:1170 let second = tokio::spawn({ spawn-task krikos/src/address_lookup.rs:1322 let handle = tokio::spawn({ spawn-task krikos/src/address_lookup.rs:1496 let handle = tokio::spawn({ spawn-task krikos/src/address_lookup/pkarr.rs:341 let join_handle = task::spawn(service.run().instrument(info_span!("pkarr_publish"))); -spawn-task krikos/src/endpoint/connection.rs:1499 tokio::spawn({ +spawn-task krikos/src/endpoint/connection.rs:1513 tokio::spawn({ spawn-task krikos/src/endpoint/handle.rs:424 /// tokio::spawn(endpoint_closed.run_until(async move { spawn-task krikos/src/endpoint/handle.rs:917 /// tokio::spawn(endpoint.closed().run_until(async move { spawn-task krikos/src/endpoint/tests.rs:1144 let p1_accept = tokio::spawn( @@ -1000,6 +1074,13 @@ spawn-task krikos/src/test_utils.rs:319 tokio::spawn(async move { spawn-task krikos/src/test_utils/test_transport.rs:529 let connect = tokio::spawn({ spawn-task krikos/tests/integration.rs:59 task::spawn({ spawn-task krikos/tests/patchbay/util.rs:437 tokio::spawn( +spawn-task protocols/krikos-identity/src/net/mod.rs:17 task::JoinSet, +spawn-task protocols/krikos-identity/src/net/mod.rs:232 tasks: JoinSet>, +spawn-task protocols/krikos-identity/src/net/mod.rs:241 tasks: JoinSet::new(), +spawn-task protocols/krikos-identity/tests/net_contracts.rs:1316 let mut tasks = JoinSet::new(); +spawn-task protocols/krikos-identity/tests/net_contracts.rs:40 task::JoinSet, +spawn-task protocols/krikos-identity/tests/provider_persistence.rs:1768 threads.push(thread::spawn(move || { +spawn-task protocols/krikos-identity/tests/provider_persistence.rs:1813 duplicate_threads.push(thread::spawn(move || { unordered-collection krikos-dns/src/endpoint_info.rs:45 collections::{BTreeSet, HashSet}, unordered-collection krikos-dns/src/endpoint_info.rs:78 fn dedup(items: &mut Vec) -> HashSet { unordered-collection krikos-dns/src/endpoint_info.rs:80 let mut seen = HashSet::new(); diff --git a/scripts/generate-identity-provider-fuzz-corpus.py b/scripts/generate-identity-provider-fuzz-corpus.py new file mode 100755 index 00000000000..51e7aec2d40 --- /dev/null +++ b/scripts/generate-identity-provider-fuzz-corpus.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Generate the provider interchange fuzz seeds from canonical interop vectors.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +MAX_FUZZ_INPUT_BYTES = 4_096 +PROVIDER_INTERCHANGE_SEEDS = ( + ("7", "provider-export-component"), + ("8", "provider-export-component-descriptor"), + ("9", "provider-generation-export-chunk"), + ("a", "provider-audit-export-chunk"), + ("b", "provider-generation-export-manifest"), + ("c", "provider-audit-export-manifest"), + ("d", "provider-recovery-export-manifest"), + ("e", "provider-compaction-manifest"), + ("f", "opaque-provider-anchor-commitment"), +) + + +def arguments() -> argparse.Namespace: + repository = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "vector_directory", + nargs="?", + type=Path, + default=repository / "protocols/krikos-identity/tests/vectors", + ) + parser.add_argument( + "output_directory", + nargs="?", + type=Path, + default=repository / "fuzz/corpus/identity_provider", + ) + return parser.parse_args() + + +def seed_name(selector: str, vector_name: str, suffix: str) -> str: + selector_code = int(selector, 16) + return f"selector-{selector_code:02x}-{vector_name}-{suffix}.bin" + + +def main() -> None: + options = arguments() + options.output_directory.mkdir(parents=True, exist_ok=True) + for selector, vector_name in PROVIDER_INTERCHANGE_SEEDS: + payload = (options.vector_directory / f"{vector_name}.bin").read_bytes() + if not payload: + raise ValueError(f"canonical vector {vector_name}.bin must not be empty") + + accepted = selector.encode("ascii") + payload + malformed = selector.encode("ascii") + payload[:-1] + if len(accepted) > MAX_FUZZ_INPUT_BYTES: + raise ValueError( + f"{vector_name} seed is {len(accepted)} bytes; " + f"maximum is {MAX_FUZZ_INPUT_BYTES}" + ) + + (options.output_directory / seed_name(selector, vector_name, "accepted")).write_bytes( + accepted + ) + ( + options.output_directory + / seed_name(selector, vector_name, "malformed-truncated") + ).write_bytes(malformed) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-identity-sync-fuzz-corpus.py b/scripts/generate-identity-sync-fuzz-corpus.py new file mode 100755 index 00000000000..220e37c406d --- /dev/null +++ b/scripts/generate-identity-sync-fuzz-corpus.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Generate exact numeric-selector sync fuzz seeds from canonical interop vectors.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +MAX_FUZZ_INPUT_BYTES = 4 * 1024 * 1024 + 1 +ACCEPTED_VECTORS = ( + (0, "sync-request", "sync-request"), + (1, "sync-frame", "sync-frame"), + (2, "sync-cursor", "sync-cursor"), + (3, "sync-response", "sync-response-frame"), + (4, "endpoint-authorization", "endpoint-authorization-request"), + (5, "authorized-sync", "authorized-sync-request"), + (6, "authorized-proposal", "authorized-proposal-request"), + (7, "authorized-checkpoint", "authorized-checkpoint-request"), + (8, "identity-protocol-ack", "identity-protocol-ack"), + (9, "identity-protocol-reply", "identity-protocol-reply-ack"), +) + + +def arguments() -> argparse.Namespace: + repository = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "vector_directory", + nargs="?", + type=Path, + default=repository / "protocols/krikos-identity/tests/vectors", + ) + parser.add_argument( + "output_directory", + nargs="?", + type=Path, + default=repository / "fuzz/corpus/identity_sync", + ) + return parser.parse_args() + + +def seed_name(selector: int, name: str, suffix: str) -> str: + return f"selector-{selector:02x}-{name}-{suffix}.bin" + + +def with_selector(selector: int, payload: bytes) -> bytes: + seed = bytes([selector]) + payload + if len(seed) > MAX_FUZZ_INPUT_BYTES: + raise ValueError( + f"selector {selector} seed is {len(seed)} bytes; " + f"maximum is {MAX_FUZZ_INPUT_BYTES}" + ) + return seed + + +def duplicate_single_head(payload: bytes) -> bytes: + # V1 + AccountId (algorithm code plus 32-byte digest) precede the bounded head vector. + head_count_offset = 1 + 33 + if payload[0] != 1 or payload[head_count_offset] != 1: + raise ValueError("sync fixture must carry v1 and exactly one source head") + head_offset = head_count_offset + 1 + head_end = head_offset + 33 + head = payload[head_offset:head_end] + if len(head) != 33: + raise ValueError("sync fixture has a truncated algorithm-tagged head") + return payload[:head_count_offset] + bytes([2]) + head + head + payload[head_end:] + + +def replace_byte(payload: bytes, offset: int, value: int) -> bytes: + changed = bytearray(payload) + if changed[offset] == value: + raise ValueError(f"replacement at offset {offset} must change the fixture") + changed[offset] = value + return bytes(changed) + + +def main() -> None: + options = arguments() + options.output_directory.mkdir(parents=True, exist_ok=True) + accepted: dict[int, tuple[str, bytes]] = {} + for selector, corpus_name, vector_name in ACCEPTED_VECTORS: + payload = (options.vector_directory / f"{vector_name}.bin").read_bytes() + if not payload: + raise ValueError(f"canonical vector {vector_name}.bin must not be empty") + accepted[selector] = (corpus_name, payload) + (options.output_directory / seed_name(selector, corpus_name, "accepted")).write_bytes( + with_selector(selector, payload) + ) + ( + options.output_directory + / seed_name(selector, corpus_name, "rejected-truncated") + ).write_bytes(with_selector(selector, payload[:-1])) + + for selector in (0, 1, 2): + corpus_name, payload = accepted[selector] + ( + options.output_directory + / seed_name(selector, corpus_name, "rejected-duplicate-head") + ).write_bytes(with_selector(selector, duplicate_single_head(payload))) + + for selector in (0, 1, 2, 3): + corpus_name, payload = accepted[selector] + ( + options.output_directory + / seed_name(selector, corpus_name, "rejected-unsupported-version") + ).write_bytes(with_selector(selector, replace_byte(payload, 0, 2))) + + response_name, response_payload = accepted[3] + ( + options.output_directory + / seed_name(3, response_name, "rejected-legacy-ordinal") + ).write_bytes(with_selector(3, replace_byte(response_payload, 1, 0))) + ( + options.output_directory + / seed_name(3, response_name, "rejected-unsupported-codepoint") + ).write_bytes(with_selector(3, replace_byte(response_payload, 1, 3))) + + for selector in (5, 6, 7): + corpus_name, payload = accepted[selector] + account_mismatch = bytearray(payload) + account_mismatch[2] ^= 0xFF + ( + options.output_directory + / seed_name(selector, corpus_name, "rejected-account-mismatch") + ).write_bytes(with_selector(selector, bytes(account_mismatch))) + + +if __name__ == "__main__": + main() diff --git a/scripts/reserve-crate-names.sh b/scripts/reserve-crate-names.sh index 32c8aca15a5..083c2194378 100755 --- a/scripts/reserve-crate-names.sh +++ b/scripts/reserve-crate-names.sh @@ -6,8 +6,9 @@ # allows yanking but never deletion — which is the accepted cost of holding a # name. See docs/adr/0002-krikos-rebrand.md. # -# Requires a crates.io token: `cargo login` first. Run with --dry-run to see -# exactly what would be published without contacting the registry. +# Publishing requires a crates.io token (`cargo login`). `--dry-run` never +# publishes, but it still queries crates.io to distinguish available names from +# names that are already registered. set -euo pipefail names=( @@ -22,6 +23,7 @@ names=( krikos-blobs krikos-docs krikos-gossip + krikos-identity krikos-sim krikos-noq krikos-hickory-server diff --git a/scripts/run-bounded-fuzz.sh b/scripts/run-bounded-fuzz.sh index dc64acd835d..2cd1060d45c 100755 --- a/scripts/run-bounded-fuzz.sh +++ b/scripts/run-bounded-fuzz.sh @@ -9,6 +9,21 @@ known_crash_seen=0 readonly fuzz_toolchain="${KRIKOS_FUZZ_TOOLCHAIN:-nightly-2026-07-19}" readonly artifact_file_limit=64 readonly artifact_byte_limit=67108864 +readonly mutable_corpus_file_limit=4096 +readonly mutable_corpus_byte_limit=268435456 +readonly fuzz_log_file_limit=1 +readonly fuzz_log_byte_limit=16777216 +readonly runtime_output_byte_limit=352321536 +readonly preflight_headroom_bytes=1073741824 +readonly watchdog_interval_seconds=0.25 +readonly log_file_block_limit=32768 +readonly fuzz_build_root="$repo_root/fuzz/target" +active_run_root="" +active_run_parent="" +active_campaign_pid="" +active_campaign_pgid="" +active_watchdog_pid="" +active_stage_file="" readonly -a targets=( doh_extract pkarr_body @@ -19,8 +34,112 @@ readonly -a targets=( app_protocol_registration blob_ticket doc_ticket + identity_foundation + identity_schema + identity_capability + identity_merkle + identity_state + identity_pairing + identity_sync + identity_provider + identity_semantics ) +process_alive() { + local process_pid="$1" + local process_state + + [[ "$process_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + kill -0 "$process_pid" 2>/dev/null || return 1 + process_state="$(ps -o stat= -p "$process_pid" 2>/dev/null)" + [[ -n "$process_state" && "$process_state" != Z* ]] +} + +process_group_alive() { + local campaign_pgid="$1" + + [[ "$campaign_pgid" =~ ^[1-9][0-9]*$ ]] || return 1 + kill -0 -- "-$campaign_pgid" 2>/dev/null || return 1 + ps -eo pgid=,stat= \ + | awk -v campaign_pgid="$campaign_pgid" \ + '$1 == campaign_pgid && $2 !~ /^Z/ { found = 1 } END { exit(found ? 0 : 1) }' +} + +terminate_campaign() { + local campaign_pgid="$1" + + [[ "$campaign_pgid" =~ ^[1-9][0-9]*$ ]] || return 0 + if process_group_alive "$campaign_pgid"; then + kill -TERM -- "-$campaign_pgid" 2>/dev/null || true + elif process_alive "$campaign_pgid"; then + kill -TERM "$campaign_pgid" 2>/dev/null || true + else + return 0 + fi + for _ in {1..20}; do + if ! process_group_alive "$campaign_pgid" \ + && ! process_alive "$campaign_pgid"; then + return 0 + fi + sleep 0.05 + done + if process_group_alive "$campaign_pgid"; then + kill -KILL -- "-$campaign_pgid" 2>/dev/null || true + fi + kill -KILL "$campaign_pgid" 2>/dev/null || true + for _ in {1..20}; do + if ! process_group_alive "$campaign_pgid" \ + && ! process_alive "$campaign_pgid"; then + return 0 + fi + sleep 0.05 + done + return 1 +} + +cleanup_active_run() { + local campaign_pid="$active_campaign_pid" + local campaign_pgid="$active_campaign_pgid" + local watchdog_pid="$active_watchdog_pid" + local run_root="$active_run_root" + local run_parent="$active_run_parent" + local stage_file="$active_stage_file" + + active_campaign_pid="" + active_campaign_pgid="" + active_watchdog_pid="" + active_run_root="" + active_run_parent="" + active_stage_file="" + if [[ -n "$campaign_pgid" ]]; then + terminate_campaign "$campaign_pgid" || true + fi + if [[ -n "$campaign_pid" ]]; then + wait "$campaign_pid" 2>/dev/null || true + fi + if [[ -n "$campaign_pgid" ]]; then + terminate_campaign "$campaign_pgid" || true + fi + if [[ "$watchdog_pid" =~ ^[1-9][0-9]*$ ]]; then + kill -TERM "$watchdog_pid" 2>/dev/null || true + wait "$watchdog_pid" 2>/dev/null || true + fi + if [[ -n "$stage_file" \ + && "$(basename -- "$stage_file")" =~ ^\.krikos-fuzz-stage\.[A-Za-z0-9]{10}$ ]]; then + rm -f -- "$stage_file" + fi + if [[ -n "$run_root" \ + && "$(dirname -- "$run_root")" == "$run_parent" \ + && "$(basename -- "$run_root")" =~ ^krikos-fuzz\.[A-Za-z0-9]{10}$ \ + && -f "$run_root/.krikos-fuzz-run" ]]; then + rm -rf -- "$run_root" + fi +} + +trap cleanup_active_run EXIT +trap 'cleanup_active_run; exit 130' INT +trap 'cleanup_active_run; exit 143' TERM + usage() { cat <<'EOF' Usage: scripts/run-bounded-fuzz.sh [--seconds N] [--target TARGET] [--artifacts DIR] @@ -41,6 +160,391 @@ is_known_target() { return 1 } +path_file_count() { + local path="$1" + + if [[ ! -e "$path" ]]; then + printf '%s\n' 0 + return 0 + fi + find "$path" -ignore_readdir_race -type f -printf '.' | wc -c +} + +path_byte_count() { + local path="$1" + local attempt output status bytes line + local saw_disappearing unexpected + + if [[ ! -e "$path" ]]; then + printf '%s\n' 0 + return 0 + fi + for attempt in 1 2 3; do + if output="$(LC_ALL=C du -sb -- "$path" 2>&1)"; then + bytes="$(awk 'NR == 1 { print $1 }' <<<"$output")" + if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then + printf 'du returned an invalid byte count for %s: %s\n' "$path" "$output" >&2 + return 1 + fi + printf '%s\n' "$bytes" + return 0 + else + status=$? + fi + + saw_disappearing=0 + unexpected=0 + while IFS= read -r line; do + if [[ "$line" == du:\ cannot\ access\ *:\ No\ such\ file\ or\ directory ]]; then + saw_disappearing=1 + elif [[ "$line" =~ ^[0-9]+[[:space:]] ]]; then + # GNU du may still print a stale total after one descendant disappears. + : + else + unexpected=1 + fi + done <<<"$output" + if (( saw_disappearing == 0 || unexpected == 1 )) || [[ ! -e "$path" ]]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + done + + printf 'could not measure %s after %s disappearing-entry retries\n' \ + "$path" "$attempt" >&2 + printf '%s\n' "$output" >&2 + return 1 +} + +file_byte_count() { + local path="$1" + + if [[ ! -f "$path" ]]; then + printf '%s\n' 0 + return 0 + fi + stat -c '%s' -- "$path" +} + +reject_managed_tree_symlinks() { + local path="$1" + local phase="$2" + local failure_file="${3:-}" + local attempt output line + local saw_disappearing unexpected + + if [[ ! -e "$path" && ! -L "$path" ]]; then + return 0 + fi + + for attempt in 1 2 3; do + if output="$(LC_ALL=C find "$path" -ignore_readdir_race -type l -print -quit 2>&1)"; then + if [[ -n "$output" ]]; then + write_budget_failure "$failure_file" \ + "managed fuzz write tree contains a symlink $phase: $output" + return 1 + fi + return 0 + fi + + saw_disappearing=0 + unexpected=0 + while IFS= read -r line; do + if [[ "$line" == "find: '$path/"*"': No such file or directory" ]]; then + saw_disappearing=1 + else + unexpected=1 + fi + done <<<"$output" + + if (( saw_disappearing == 0 || unexpected == 1 )); then + [[ -z "$output" ]] || printf '%s\n' "$output" >&2 + write_budget_failure "$failure_file" \ + "could not inspect managed fuzz tree $phase: $path" + return 1 + fi + # Cargo can remove a temporary descendant while find traverses target/. + # Require a later complete traversal instead of accepting a partial scan. + done + + [[ -z "$output" ]] || printf '%s\n' "$output" >&2 + write_budget_failure "$failure_file" \ + "could not inspect managed fuzz tree $phase: $path" + return 1 +} + +reject_managed_artifact_aliases() { + local path="$1" + local phase="$2" + local failure_file="${3:-}" + local invalid + + invalid="$(find "$path" -ignore_readdir_race ! -type d ! -type f ! -type l -print -quit)" || { + write_budget_failure "$failure_file" \ + "could not inspect managed fuzz artifact tree $phase: $path" + return 1 + } + if [[ -n "$invalid" ]]; then + write_budget_failure "$failure_file" \ + "managed fuzz artifact tree contains a non-regular entry $phase: $invalid" + return 1 + fi + invalid="$(find "$path" -ignore_readdir_race -type f -links +1 -print -quit)" || { + write_budget_failure "$failure_file" \ + "could not inspect managed fuzz artifact link counts $phase: $path" + return 1 + } + if [[ -n "$invalid" ]]; then + write_budget_failure "$failure_file" \ + "managed fuzz artifact tree contains a multiply-linked regular file $phase: $invalid" + return 1 + fi +} + +reject_nonregular_artifact_output() { + local path="$1" + + if [[ -e "$path" && ! -f "$path" ]]; then + printf 'managed fuzz artifact output is not a regular file: %s\n' "$path" >&2 + return 1 + fi +} + +ensure_managed_directory() { + local path="$1" + local label="$2" + + if [[ -L "$path" ]]; then + printf 'managed fuzz write tree contains a symlink before setup: %s\n' "$path" >&2 + return 1 + fi + if [[ -e "$path" && ! -d "$path" ]]; then + printf '%s is not a directory: %s\n' "$label" "$path" >&2 + return 1 + fi + mkdir -p -- "$path" + if [[ ! -d "$path" || -L "$path" ]]; then + printf '%s is not a safe managed directory: %s\n' "$label" "$path" >&2 + return 1 + fi + reject_managed_tree_symlinks "$path" 'before setup' +} + +free_byte_count() { + local path="$1" + local available_blocks + + available_blocks="$(df -Pk -- "$path" | awk 'NR == 2 { print $4 }')" + if [[ ! "$available_blocks" =~ ^[0-9]+$ ]]; then + printf 'could not determine free space for fuzz path: %s\n' "$path" >&2 + return 1 + fi + printf '%s\n' "$(( available_blocks * 1024 ))" +} + +preflight_free_space() { + local path="$1" + local available_bytes required_bytes + + available_bytes="$(free_byte_count "$path")" + required_bytes=$(( runtime_output_byte_limit + preflight_headroom_bytes )) + if (( available_bytes < required_bytes )); then + printf 'insufficient free space for bounded fuzz run: path=%s available=%s required=%s\n' \ + "$path" "$available_bytes" "$required_bytes" >&2 + return 1 + fi +} + +write_budget_failure() { + local failure_file="$1" + shift + + if [[ -n "$failure_file" ]]; then + printf '%s\n' "$*" > "$failure_file" + else + printf '%s\n' "$*" >&2 + fi +} + +check_runtime_budgets() { + local phase="$1" + local run_corpus="$2" + local artifact_root="$3" + local fuzz_log="$4" + local failure_file="$5" + local build_root="$6" + local repository_root="$7" + local corpus_files corpus_bytes artifact_files artifact_bytes log_files log_bytes aggregate_bytes + local corpus_free_bytes artifact_free_bytes log_free_bytes build_free_bytes repository_free_bytes + local log_root + + reject_managed_tree_symlinks "$run_corpus" "$phase" "$failure_file" || return 1 + reject_managed_tree_symlinks "$artifact_root" "$phase" "$failure_file" || return 1 + reject_managed_artifact_aliases "$artifact_root" "$phase" "$failure_file" || return 1 + reject_managed_tree_symlinks "$build_root" "$phase" "$failure_file" || return 1 + if [[ -n "$fuzz_log" ]]; then + reject_managed_tree_symlinks "$fuzz_log" "$phase" "$failure_file" || return 1 + fi + + corpus_files="$(path_file_count "$run_corpus")" + corpus_bytes="$(path_byte_count "$run_corpus")" + artifact_files="$(path_file_count "$artifact_root")" + artifact_bytes="$(path_byte_count "$artifact_root")" + if [[ -f "$fuzz_log" ]]; then + log_files=1 + else + log_files=0 + fi + log_bytes="$(file_byte_count "$fuzz_log")" + aggregate_bytes=$(( corpus_bytes + artifact_bytes + log_bytes )) + corpus_free_bytes="$(free_byte_count "$run_corpus")" + artifact_free_bytes="$(free_byte_count "$artifact_root")" + log_root="${fuzz_log:+$(dirname -- "$fuzz_log")}" + if [[ -n "$log_root" ]]; then + log_free_bytes="$(free_byte_count "$log_root")" + else + log_free_bytes="$artifact_free_bytes" + fi + build_free_bytes="$(free_byte_count "$build_root")" + repository_free_bytes="$(free_byte_count "$repository_root")" + + if (( corpus_files > mutable_corpus_file_limit || corpus_bytes > mutable_corpus_byte_limit )); then + write_budget_failure "$failure_file" \ + "mutable corpus budget exceeded $phase: files=$corpus_files bytes=$corpus_bytes" + return 1 + fi + if (( artifact_files > artifact_file_limit || artifact_bytes > artifact_byte_limit )); then + write_budget_failure "$failure_file" \ + "artifact budget exceeded $phase: files=$artifact_files bytes=$artifact_bytes" + return 1 + fi + if (( log_files > fuzz_log_file_limit || log_bytes > fuzz_log_byte_limit )); then + write_budget_failure "$failure_file" \ + "fuzz log budget exceeded $phase: files=$log_files bytes=$log_bytes" + return 1 + fi + if (( aggregate_bytes > runtime_output_byte_limit )); then + write_budget_failure "$failure_file" \ + "aggregate runtime-output budget exceeded $phase: bytes=$aggregate_bytes" + return 1 + fi + if (( corpus_free_bytes < preflight_headroom_bytes \ + || artifact_free_bytes < preflight_headroom_bytes \ + || log_free_bytes < preflight_headroom_bytes \ + || build_free_bytes < preflight_headroom_bytes \ + || repository_free_bytes < preflight_headroom_bytes )); then + write_budget_failure "$failure_file" \ + "free-space headroom exhausted $phase: corpus_free=$corpus_free_bytes artifact_free=$artifact_free_bytes log_free=$log_free_bytes build_free=$build_free_bytes repository_free=$repository_free_bytes required=$preflight_headroom_bytes" + return 1 + fi +} + +budget_watchdog() { + local campaign_pid="$1" + local campaign_pgid="$2" + local run_corpus="$3" + local artifact_root="$4" + local fuzz_log="$5" + local failure_file="$6" + local build_root="$7" + local repository_root="$8" + local group_observed=0 + + for _ in {1..100}; do + if process_group_alive "$campaign_pgid"; then + group_observed=1 + break + fi + process_alive "$campaign_pid" || break + sleep 0.01 + done + if (( group_observed == 0 )) && process_alive "$campaign_pid"; then + write_budget_failure "$failure_file" \ + 'fuzz campaign did not establish its isolated process group' + terminate_campaign "$campaign_pgid" || true + return 1 + fi + + while process_group_alive "$campaign_pgid"; do + if ! check_runtime_budgets \ + 'during execution' "$run_corpus" "$artifact_root" "$fuzz_log" "$failure_file" \ + "$build_root" "$repository_root"; then + terminate_campaign "$campaign_pgid" || true + return 1 + fi + sleep "$watchdog_interval_seconds" + done + check_runtime_budgets \ + 'after execution' "$run_corpus" "$artifact_root" "$fuzz_log" "$failure_file" \ + "$build_root" "$repository_root" +} + +retain_bounded_artifact_file() { + local source="$1" + local destination="$2" + local run_corpus="$3" + local label="$4" + local destination_parent source_bytes artifact_files artifact_bytes + local staging_files staging_bytes corpus_bytes aggregate_bytes + local artifact_free_bytes required_free_bytes staged_bytes + + if [[ ! -f "$source" || -L "$source" ]]; then + printf 'retained %s source is not a regular file: %s\n' "$label" "$source" >&2 + return 1 + fi + reject_managed_artifact_aliases "$artifacts" "before $label staging" + reject_nonregular_artifact_output "$destination" + source_bytes="$(file_byte_count "$source")" + artifact_files="$(path_file_count "$artifacts")" + artifact_bytes="$(path_byte_count "$artifacts")" + staging_files=$(( artifact_files + 1 )) + staging_bytes=$(( artifact_bytes + source_bytes )) + if (( staging_files > artifact_file_limit )); then + printf 'retained %s would exceed artifact file budget before staging: files=%s\n' \ + "$label" "$staging_files" >&2 + return 1 + fi + if (( staging_bytes > artifact_byte_limit )); then + printf 'retained %s would exceed artifact byte budget before staging: bytes=%s\n' \ + "$label" "$staging_bytes" >&2 + return 1 + fi + corpus_bytes="$(path_byte_count "$run_corpus")" + aggregate_bytes=$(( corpus_bytes + staging_bytes + source_bytes )) + if (( aggregate_bytes > runtime_output_byte_limit )); then + printf 'retained %s would exceed aggregate runtime-output budget before staging: bytes=%s\n' \ + "$label" "$aggregate_bytes" >&2 + return 1 + fi + artifact_free_bytes="$(free_byte_count "$artifacts")" + required_free_bytes=$(( preflight_headroom_bytes + source_bytes )) + if (( artifact_free_bytes < required_free_bytes )); then + printf 'insufficient artifact free space for retained %s staging: available=%s required=%s\n' \ + "$label" "$artifact_free_bytes" "$required_free_bytes" >&2 + return 1 + fi + + destination_parent="$(dirname -- "$destination")" + active_stage_file="$(mktemp "$destination_parent/.krikos-fuzz-stage.XXXXXXXXXX")" + cp --reflink=never -- "$source" "$active_stage_file" + staged_bytes="$(file_byte_count "$active_stage_file")" + if (( staged_bytes != source_bytes )); then + printf 'retained %s staging changed byte length: source=%s staged=%s\n' \ + "$label" "$source_bytes" "$staged_bytes" >&2 + return 1 + fi + check_runtime_budgets \ + "during $label staging" "$run_corpus" "$artifacts" "$source" "" \ + "$fuzz_build_root" "$repo_root" + reject_nonregular_artifact_output "$destination" + mv -Tf -- "$active_stage_file" "$destination" + active_stage_file="" + rm -f -- "$source" + check_runtime_budgets \ + "after $label retention" "$run_corpus" "$artifacts" "" "" \ + "$fuzz_build_root" "$repo_root" +} + while [[ $# -gt 0 ]]; do case "$1" in --seconds) @@ -85,6 +589,11 @@ command -v cargo >/dev/null 2>&1 || { exit 2 } +command -v setsid >/dev/null 2>&1 || { + printf '%s\n' 'setsid is required for bounded fuzz process-group cleanup' >&2 + exit 2 +} + if ! cargo fuzz --help >/dev/null 2>&1; then printf '%s\n' 'cargo-fuzz is required: cargo install cargo-fuzz --locked' >&2 exit 2 @@ -97,18 +606,27 @@ if [[ ! "$fuzz_target" =~ ^[A-Za-z0-9_.-]+$ ]]; then exit 2 fi -mkdir -p "$artifacts" -artifacts="$(cd "$artifacts" && pwd)" +ensure_managed_directory "$artifacts" 'fuzz artifact root' +artifacts="$(cd "$artifacts" && pwd -P)" +reject_managed_artifact_aliases "$artifacts" 'before setup' +ensure_managed_directory "$fuzz_build_root" 'fuzz build target root' known_crashes_file="$repo_root/fuzz/known-crashes.md" run_target() { local target="$1" local max_len=65535 local source_corpus="$repo_root/fuzz/corpus/$target" - local run_corpus local target_artifacts="$artifacts/$target" - local file_count - local byte_count + local run_corpus fuzz_log budget_failure_file + local temp_parent fuzz_rustflags command_text quoted_command + local start_seconds end_seconds wall_seconds fuzz_status watchdog_status + local campaign_pid campaign_pgid + local executed_units peak_rss_mb run_result + local corpus_files corpus_bytes artifact_files artifact_bytes + local summary summary_temp summary_stable summary_text + local summary_base_files summary_base_bytes summary_bytes_guess summary_bytes_actual + local summary_final_files summary_final_bytes summary_existing_files summary_existing_bytes + local -a fuzz_command case "$target" in pkarr_body) @@ -118,19 +636,79 @@ run_target() { # Ten framing-control bytes plus the production 64 KiB relay payload bound. max_len=65546 ;; + identity_foundation) + # Covers the complete bounded extension envelope plus framing overhead. + max_len=131072 + ;; + identity_schema) + # One dispatch byte plus the largest canonical identity object. + max_len=1048577 + ;; + identity_capability) + # Fixed evaluator controls; all constructed grants and chains remain protocol-bounded. + max_len=64 + ;; + identity_merkle) + # One dispatch byte plus the global canonical-object bound. + max_len=1048577 + ;; + identity_state) + # The evaluator model caps itself at sixteen transitions and two fork branches. + max_len=64 + ;; + identity_pairing) + # One dispatch byte plus the pairing/proposal/presence canonical-object bound. + max_len=262145 + ;; + identity_sync) + # One dispatch byte plus the exact synchronization-frame bound. + max_len=4194305 + ;; + identity_provider) + # Bounded fault controls plus small persistent-provider mutation payloads. + max_len=4096 + ;; + identity_semantics) + # One selector plus the largest algorithm-tagged leaf accepted by this target. + max_len=8209 + ;; esac - run_corpus="$(mktemp -d)" - trap 'rm -rf "$run_corpus"' RETURN + temp_parent="${TMPDIR:-/tmp}" + [[ -d "$temp_parent" ]] || { + printf 'temporary fuzz parent does not exist: %s\n' "$temp_parent" >&2 + exit 1 + } + preflight_free_space "$temp_parent" + preflight_free_space "$artifacts" + preflight_free_space "$fuzz_build_root" + preflight_free_space "$repo_root" + reject_managed_tree_symlinks "$source_corpus" 'before reviewed-corpus copy' + check_runtime_budgets \ + 'before reviewed-corpus copy' "$source_corpus" "$artifacts" "" "" \ + "$fuzz_build_root" "$repo_root" + + active_run_parent="$(cd "$temp_parent" && pwd)" + active_run_root="$(mktemp -d "$active_run_parent/krikos-fuzz.XXXXXXXXXX")" + active_run_root="$(cd "$active_run_root" && pwd)" + : > "$active_run_root/.krikos-fuzz-run" + run_corpus="$active_run_root/corpus" + fuzz_log="$active_run_root/fuzz-output.txt" + budget_failure_file="$active_run_root/budget-failure.txt" + mkdir -p "$run_corpus" cp -a "$source_corpus/." "$run_corpus/" - mkdir -p "$target_artifacts" + reject_managed_tree_symlinks "$run_corpus" 'after reviewed-corpus copy' + ensure_managed_directory "$target_artifacts" 'fuzz artifact target' + reject_managed_artifact_aliases "$target_artifacts" 'before execution' + reject_nonregular_artifact_output "$target_artifacts/fuzz-output.txt" + reject_nonregular_artifact_output "$target_artifacts/run-summary.txt" + check_runtime_budgets \ + 'before execution' "$run_corpus" "$artifacts" "$fuzz_log" "" \ + "$fuzz_build_root" "$repo_root" - local fuzz_log="$target_artifacts/fuzz-output.txt" - local fuzz_status=0 - ( - cd "$repo_root" - RUSTFLAGS="${KRIKOS_FUZZ_RUSTFLAGS:--A deprecated}" \ - cargo "+$fuzz_toolchain" fuzz run --target "$fuzz_target" "$target" "$run_corpus" -- \ + fuzz_rustflags="${KRIKOS_FUZZ_RUSTFLAGS:--A deprecated}" + fuzz_command=( + cargo "+$fuzz_toolchain" fuzz run --target "$fuzz_target" "$target" "$run_corpus" -- "-max_total_time=$seconds" \ -timeout=10 \ -rss_limit_mb=2048 \ @@ -138,8 +716,92 @@ run_target() { "-artifact_prefix=$target_artifacts/" \ -verbosity=0 \ -print_final_stats=1 - ) 2>&1 | tee "$fuzz_log" || fuzz_status="${PIPESTATUS[0]}" + ) + printf -v command_text 'CARGO_TARGET_DIR=%q RUSTFLAGS=%q ' \ + "$fuzz_build_root" "$fuzz_rustflags" + printf -v quoted_command '%q ' "${fuzz_command[@]}" + command_text+="${quoted_command% }" + + start_seconds="$(date +%s)" + setsid bash -o pipefail -c ' + repo_root=$1 + fuzz_rustflags=$2 + fuzz_log=$3 + log_file_block_limit=$4 + fuzz_build_root=$5 + shift 5 + cd "$repo_root" + CARGO_TARGET_DIR="$fuzz_build_root" RUSTFLAGS="$fuzz_rustflags" "$@" 2>&1 \ + | (ulimit -f "$log_file_block_limit"; exec tee "$fuzz_log") + campaign_status=${PIPESTATUS[0]} + exit "$campaign_status" + ' _ "$repo_root" "$fuzz_rustflags" "$fuzz_log" "$log_file_block_limit" \ + "$fuzz_build_root" \ + "${fuzz_command[@]}" & + active_campaign_pid=$! + active_campaign_pgid=$active_campaign_pid + campaign_pid=$active_campaign_pid + campaign_pgid=$active_campaign_pgid + budget_watchdog \ + "$campaign_pid" "$campaign_pgid" "$run_corpus" "$artifacts" "$fuzz_log" \ + "$budget_failure_file" \ + "$fuzz_build_root" "$repo_root" & + active_watchdog_pid=$! + + if wait "$campaign_pid"; then + fuzz_status=0 + else + fuzz_status=$? + fi + if process_group_alive "$campaign_pgid"; then + write_budget_failure "$budget_failure_file" \ + 'fuzz campaign left a live process-group descendant after leader exit' + terminate_campaign "$campaign_pgid" || true + fi + if wait "$active_watchdog_pid"; then + watchdog_status=0 + else + watchdog_status=$? + fi + active_watchdog_pid="" + if process_group_alive "$campaign_pgid"; then + terminate_campaign "$campaign_pgid" || true + fi + if process_group_alive "$campaign_pgid"; then + write_budget_failure "$budget_failure_file" \ + 'fuzz campaign process group remained live after forced termination' + watchdog_status=1 + else + active_campaign_pid="" + active_campaign_pgid="" + fi + end_seconds="$(date +%s)" + wall_seconds=$(( end_seconds - start_seconds )) + if [[ -s "$budget_failure_file" ]]; then + cat "$budget_failure_file" >&2 + cleanup_active_run + exit 1 + fi + if (( watchdog_status != 0 )); then + printf 'runtime budget watchdog failed for %s without a diagnostic\n' "$target" >&2 + cleanup_active_run + exit 1 + fi + + executed_units="$(sed -n \ + 's/^stat::number_of_executed_units:[[:space:]]*\([0-9][0-9]*\).*$/\1/p' \ + "$fuzz_log" | tail -1)" + peak_rss_mb="$(sed -n \ + 's/^stat::peak_rss_mb:[[:space:]]*\([0-9][0-9]*\).*$/\1/p' \ + "$fuzz_log" | tail -1)" + [[ "$executed_units" =~ ^[0-9]+$ ]] || executed_units=unavailable + [[ "$peak_rss_mb" =~ ^[0-9]+$ ]] || peak_rss_mb=unavailable + retain_bounded_artifact_file \ + "$fuzz_log" "$target_artifacts/fuzz-output.txt" "$run_corpus" 'fuzz log' + fuzz_log="$target_artifacts/fuzz-output.txt" + + run_result=clean if (( fuzz_status != 0 )); then # A Rust panic prints "panicked at :::" followed by the # panic message on the next line. Build a signature out of BOTH the @@ -163,31 +825,79 @@ run_target() { if [[ -n "$signature" ]] && grep -qF -- "$signature" "$known_crashes_file" 2>/dev/null; then printf 'known crash reproduced for %s: %s\n' "$target" "$signature" >&2 known_crash_seen=1 + run_result=known-crash else printf 'NEW crash for %s: %s\n' "$target" "${signature:-}" >&2 + cleanup_active_run exit 1 fi fi - file_count="$(find "$target_artifacts" -type f | wc -l)" - byte_count="$(du -sb "$target_artifacts" | awk '{print $1}')" - if (( file_count >= artifact_file_limit || byte_count > artifact_byte_limit )); then - printf 'artifact budget exceeded for %s: files=%s bytes=%s\n' \ - "$target" "$file_count" "$byte_count" >&2 - exit 1 + summary="$target_artifacts/run-summary.txt" + reject_managed_artifact_aliases "$target_artifacts" 'before summary retention' + reject_nonregular_artifact_output "$summary" + artifact_files="$(path_file_count "$artifacts")" + artifact_bytes="$(path_byte_count "$artifacts")" + if [[ -f "$summary" ]]; then + summary_existing_files=1 + summary_existing_bytes="$(file_byte_count "$summary")" + else + summary_existing_files=0 + summary_existing_bytes=0 fi + summary_base_files=$(( artifact_files - summary_existing_files )) + summary_base_bytes=$(( artifact_bytes - summary_existing_bytes )) + summary_final_files=$(( summary_base_files + 1 )) + summary_bytes_guess=0 + summary_temp="$(mktemp "$active_run_root/run-summary.XXXXXXXXXX")" - printf 'target=%s seconds=%s max_len=%s artifact_files=%s artifact_bytes=%s\n' \ - "$target" "$seconds" "$max_len" "$file_count" "$byte_count" \ - > "$target_artifacts/run-summary.txt" - - file_count="$(find "$target_artifacts" -type f | wc -l)" - byte_count="$(du -sb "$target_artifacts" | awk '{print $1}')" - if (( file_count > artifact_file_limit || byte_count > artifact_byte_limit )); then - printf 'artifact budget exceeded for %s after summary: files=%s bytes=%s\n' \ - "$target" "$file_count" "$byte_count" >&2 + summary_stable=0 + for _ in {1..8}; do + corpus_files="$(path_file_count "$run_corpus")" + corpus_bytes="$(path_byte_count "$run_corpus")" + summary_final_bytes=$(( summary_base_bytes + summary_bytes_guess )) + printf '%s\n' \ + "target=$target" \ + "result=$run_result" \ + "toolchain=$fuzz_toolchain" \ + "command=$command_text" \ + "seconds=$seconds" \ + "wall_seconds=$wall_seconds" \ + "max_len=$max_len" \ + "rss_limit_mb=2048" \ + "executed_units=$executed_units" \ + "peak_rss_mb=$peak_rss_mb" \ + "mutable_corpus_files=$corpus_files" \ + "mutable_corpus_bytes=$corpus_bytes" \ + "corpus_result=within-budget" \ + "artifact_files=$summary_final_files" \ + "artifact_bytes=$summary_final_bytes" \ + "artifact_result=within-budget" \ + > "$summary_temp" + summary_bytes_actual="$(file_byte_count "$summary_temp")" + if (( summary_bytes_actual == summary_bytes_guess )); then + summary_stable=1 + break + fi + summary_bytes_guess="$summary_bytes_actual" + done + if (( summary_stable == 0 )); then + printf 'run summary byte accounting did not converge for %s\n' "$target" >&2 + cleanup_active_run + exit 1 + fi + summary_text="$(<"$summary_temp")" + retain_bounded_artifact_file \ + "$summary_temp" "$summary" "$run_corpus" 'run summary' + artifact_files="$(path_file_count "$artifacts")" + artifact_bytes="$(path_byte_count "$artifacts")" + if (( artifact_files != summary_final_files || artifact_bytes != summary_final_bytes )); then + printf 'run summary accounting changed after atomic retention for %s\n' "$target" >&2 + cleanup_active_run exit 1 fi + printf '%s\n' "$summary_text" + cleanup_active_run } if [[ -n "$selected_target" ]]; then diff --git a/scripts/test-local-first-framework.sh b/scripts/test-local-first-framework.sh index 36b0c739d36..af5e026c23d 100755 --- a/scripts/test-local-first-framework.sh +++ b/scripts/test-local-first-framework.sh @@ -4,11 +4,20 @@ set -euo pipefail repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) cd "$repo_root" +toolchain="${KRIKOS_IDENTITY_TOOLCHAIN:-1.91.0}" python3 scripts/check-framework-release-gate.py --expect-closed +python3 scripts/check-identity-release-gate.py --expect-closed scripts/check-framework-package-layout.sh -cargo test -p krikos-app --all-features -cargo test -p krikos-docs migration -cargo test -p krikos-local-first-app-tests --test two_node +scripts/check-identity-feature-matrix.sh +cargo "+$toolchain" test --locked -p krikos-app --all-features +scripts/check-identity-interop-vectors.sh +scripts/check-identity-wire-inventory.sh +scripts/check-identity-doc-links.py +scripts/check-identity-model.sh +cargo "+$toolchain" run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- \ + identity corpus-test krikos-sim/identity-corpus +cargo "+$toolchain" test --locked -p krikos-docs migration +cargo "+$toolchain" test --locked -p krikos-local-first-app-tests --test two_node scripts/tests/check-blobs-v0-interop.sh scripts/tests/check-gossip-v0-interop.sh diff --git a/scripts/tests/check-determinism-boundaries.sh b/scripts/tests/check-determinism-boundaries.sh index 98511ef9fa0..c56ecfdbc3f 100755 --- a/scripts/tests/check-determinism-boundaries.sh +++ b/scripts/tests/check-determinism-boundaries.sh @@ -7,12 +7,16 @@ checker="$repo_root/scripts/check-determinism-boundaries.sh" fixture_root=$(mktemp -d) trap 'rm -rf "$fixture_root"' EXIT -mkdir -p "$fixture_root/krikos/src" "$fixture_root/krikos-runtime/src" "$fixture_root/scripts" +mkdir -p \ + "$fixture_root/krikos/src" \ + "$fixture_root/krikos-runtime/src" \ + "$fixture_root/protocols/krikos-identity/src" \ + "$fixture_root/scripts" # The checker's SOURCE_ROOTS list is fixed; every root must exist below # --root and contain at least one .rs file, or the checker now errors # loudly (neither a missing root nor a present-but-empty root may silently -# narrow the scan). Only krikos/ and krikos-runtime/ carry the fixture -# content the assertions below exercise -- the rest each get one trivial +# narrow the scan). Krikos, krikos-runtime, and the identity protocol carry +# the fixture content exercised below; the rest each get one trivial # placeholder .rs file so they satisfy the per-root minimum without # contributing any boundary occurrences. placeholder_roots=(krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-sim) @@ -23,13 +27,22 @@ done baseline="$fixture_root/scripts/determinism-boundaries.txt" source_file="$fixture_root/krikos/src/lib.rs" runtime_source="$fixture_root/krikos-runtime/src/lib.rs" +identity_source="$fixture_root/protocols/krikos-identity/src/lib.rs" printf '%s\n' 'pub fn deterministic() {}' > "$source_file" printf '%s\n' 'pub fn runtime_capability() {}' > "$runtime_source" +printf '%s\n' \ + 'pub fn secure_entropy(bytes: &mut [u8]) { getrandom::fill(bytes).unwrap(); }' \ + > "$identity_source" "$checker" --update --root "$fixture_root" --baseline "$baseline" "$checker" --check --root "$fixture_root" --baseline "$baseline" +if ! grep -Fq $'entropy-random\tprotocols/krikos-identity/src/lib.rs:1\tpub fn secure_entropy(bytes: &mut [u8]) { getrandom::fill(bytes).unwrap(); }' "$baseline"; then + echo "updated baseline does not include the identity protocol source root" >&2 + exit 1 +fi + printf '%s\n' 'pub fn detached() { tokio::spawn(async {}); }' >> "$source_file" if output=$("$checker" --check --root "$fixture_root" --baseline "$baseline" 2>&1); then diff --git a/scripts/tests/check-determinism-semantic.sh b/scripts/tests/check-determinism-semantic.sh index 07f3c39ed55..654e005c218 100755 --- a/scripts/tests/check-determinism-semantic.sh +++ b/scripts/tests/check-determinism-semantic.sh @@ -7,12 +7,15 @@ checker="$repo_root/scripts/check-determinism-semantic.sh" fixture_root=$(mktemp -d) trap 'rm -rf "$fixture_root"' EXIT -mkdir -p "$fixture_root/krikos/src" "$fixture_root/scripts" +mkdir -p \ + "$fixture_root/krikos/src" \ + "$fixture_root/protocols/krikos-identity/src" \ + "$fixture_root/scripts" # The Rust checker's SOURCE_ROOTS list is fixed; every root must exist below # --root and contain at least one .rs file, or it now errors loudly # (neither a missing root nor a present-but-empty root may silently narrow -# the scan). Only krikos/ carries the fixture content the assertions below -# exercise -- the rest each get one trivial placeholder .rs file so they +# the scan). Krikos and the identity protocol carry the fixture content +# exercised below; the rest each get one trivial placeholder .rs file so they # satisfy the per-root minimum without contributing any boundary # occurrences. placeholder_roots=(krikos-base krikos-resolver krikos-dns krikos-dns-server krikos-relay krikos-runtime krikos-sim) @@ -21,6 +24,7 @@ for placeholder in "${placeholder_roots[@]}"; do printf '%s\n' '// placeholder crate root for the fixture' > "$fixture_root/$placeholder/src/lib.rs" done source_file="$fixture_root/krikos/src/lib.rs" +identity_source="$fixture_root/protocols/krikos-identity/src/lib.rs" baseline="$fixture_root/scripts/determinism-boundaries.semantic.txt" printf '%s\n' \ @@ -28,6 +32,10 @@ printf '%s\n' \ 'const NOTE: &str = "tokio::spawn(async {})";' \ 'pub fn timestamp() { let _ = WallClock::now(); }' \ > "$source_file" +printf '%s\n' \ + 'pub fn secure_entropy(bytes: &mut [u8]) { let _ = getrandom::fill(bytes); }' \ + 'pub fn secure_entropy_reference() { let _fill = getrandom::fill; }' \ + > "$identity_source" "$checker" --update --root "$fixture_root" --baseline "$baseline" "$checker" --check --root "$fixture_root" --baseline "$baseline" @@ -40,6 +48,14 @@ if grep -Fq 'spawn-task' "$baseline"; then echo "semantic checker treated string content as executable Rust" >&2 exit 1 fi +if ! grep -Fq $'entropy-random\tprotocols/krikos-identity/src/lib.rs\tsecure_entropy\tgetrandom::fill\t1' "$baseline"; then + echo "semantic checker did not scan identity or classify getrandom::fill" >&2 + exit 1 +fi +if ! grep -Fq $'entropy-random\tprotocols/krikos-identity/src/lib.rs\tsecure_entropy_reference\tgetrandom::fill\t1' "$baseline"; then + echo "semantic checker did not classify an entropy function used as a value" >&2 + exit 1 +fi sed -i '1i\\' "$source_file" "$checker" --check --root "$fixture_root" --baseline "$baseline" diff --git a/scripts/tests/check-fuzz-tooling.sh b/scripts/tests/check-fuzz-tooling.sh index 4c231e351a2..4a881976757 100755 --- a/scripts/tests/check-fuzz-tooling.sh +++ b/scripts/tests/check-fuzz-tooling.sh @@ -14,7 +14,20 @@ scheduled_workflow="$repo_root/.github/workflows/fuzz.yml" # rather than a SHA that Dependabot will rotate on every bump. normalized_ci_workflow=$(mktemp) normalized_scheduled_workflow=$(mktemp) -trap 'rm -f "$normalized_ci_workflow" "$normalized_scheduled_workflow"' EXIT +fixture_root="" +cleanup() { + if [[ -n "$fixture_root" && -f "$fixture_root/fake-sleep-pid" ]]; then + fake_sleep_pid=$(<"$fixture_root/fake-sleep-pid") + if [[ "$fake_sleep_pid" =~ ^[0-9]+$ ]]; then + kill -TERM "$fake_sleep_pid" 2>/dev/null || true + fi + fi + rm -f "$normalized_ci_workflow" "$normalized_scheduled_workflow" + if [[ -n "$fixture_root" && -f "$fixture_root/.fuzz-tooling-fixture" ]]; then + rm -rf -- "$fixture_root" + fi +} +trap cleanup EXIT sed -E 's/@[0-9a-f]{40}[[:space:]]+#[[:space:]]*([^[:space:]]+)/@\1/' "$ci_workflow" > "$normalized_ci_workflow" sed -E 's/@[0-9a-f]{40}[[:space:]]+#[[:space:]]*([^[:space:]]+)/@\1/' "$scheduled_workflow" > "$normalized_scheduled_workflow" ci_workflow="$normalized_ci_workflow" @@ -30,6 +43,15 @@ readonly -a targets=( app_protocol_registration blob_ticket doc_ticket + identity_foundation + identity_schema + identity_capability + identity_merkle + identity_state + identity_pairing + identity_sync + identity_provider + identity_semantics ) [[ -x "$runner" ]] || { @@ -87,6 +109,58 @@ for target in "${targets[@]}"; do } done +if rg -n 'selector[^[:cntrl:]]*%' "$repo_root"/fuzz/fuzz_targets/identity_*.rs >&2; then + printf '%s\n' 'identity fuzz selectors must be append-only and must not use modulo remapping' >&2 + exit 1 +fi + +provider_selector_contracts=( + '7|ProviderExportComponent' + '8|ProviderExportComponentDescriptor' + '9|ProviderGenerationExportChunk' + 'a|ProviderAuditExportChunk' + 'b|ProviderGenerationExportManifest' + 'c|ProviderAuditExportManifest' + 'd|ProviderRecoveryExportManifest' + 'e|ProviderCompactionManifest' + 'f|OpaqueProviderAnchorCommitment' +) +for contract in "${provider_selector_contracts[@]}"; do + selector="${contract%%|*}" + wire_type="${contract#*|}" + grep -F -A2 -- "b'$selector' => {" \ + "$repo_root/fuzz/fuzz_targets/identity_provider.rs" \ + | grep -Fq -- "$wire_type::from_canonical_bytes(&input[1..])" || { + printf 'provider fuzz selector %s does not decode %s\n' "$selector" "$wire_type" >&2 + exit 1 + } +done +grep -Fq -- "checked_sub(b'0').filter(|selector| *selector < 7)" \ + "$repo_root/fuzz/fuzz_targets/identity_provider.rs" + +sync_selector_contracts=( + '0|SyncRequest' + '1|SyncFrame' + '2|SyncCursor' + '3|SyncResponse' + '4|EndpointAuthorizationRequest' + '5|AuthorizedSyncRequest' + '6|AuthorizedProposalRequest' + '7|AuthorizedCheckpointRequest' + '8|IdentityProtocolAck' + '9|IdentityProtocolReply' +) +for contract in "${sync_selector_contracts[@]}"; do + selector="${contract%%|*}" + wire_type="${contract#*|}" + grep -F -A8 -- "$selector => {" \ + "$repo_root/fuzz/fuzz_targets/identity_sync.rs" \ + | grep -Fq -- "$wire_type::from_canonical_bytes(bytes)" || { + printf 'sync fuzz selector %s does not decode %s\n' "$selector" "$wire_type" >&2 + exit 1 + } +done + if "$runner" --seconds 0 >/dev/null 2>&1; then printf '%s\n' 'runner accepted an unbounded zero-second campaign' >&2 exit 1 @@ -100,6 +174,33 @@ fi grep -Fq -- '-rss_limit_mb=2048' "$runner" grep -Fq -- 'artifact_file_limit=64' "$runner" grep -Fq -- 'artifact_byte_limit=67108864' "$runner" +grep -Fq -- 'mutable_corpus_file_limit=4096' "$runner" +grep -Fq -- 'mutable_corpus_byte_limit=268435456' "$runner" +grep -Fq -- 'fuzz_log_file_limit=1' "$runner" +grep -Fq -- 'fuzz_log_byte_limit=16777216' "$runner" +grep -Fq -- 'runtime_output_byte_limit=352321536' "$runner" +grep -Fq -- 'preflight_headroom_bytes=1073741824' "$runner" +grep -Fq -- 'budget_watchdog' "$runner" +grep -Fq -- 'free-space headroom exhausted' "$runner" +grep -Fq -- 'trap cleanup_active_run EXIT' "$runner" +grep -Fq -- "trap 'cleanup_active_run; exit 130' INT" "$runner" +grep -Fq -- "trap 'cleanup_active_run; exit 143' TERM" "$runner" +grep -Fq -- 'stat::number_of_executed_units' "$runner" +grep -Fq -- 'stat::peak_rss_mb' "$runner" +grep -Fq -- '"executed_units=$executed_units"' "$runner" +grep -Fq -- '"peak_rss_mb=$peak_rss_mb"' "$runner" +grep -Fq -- '"wall_seconds=$wall_seconds"' "$runner" +grep -Fq -- '"command=$command_text"' "$runner" +grep -Fq -- 'corpus_result=within-budget' "$runner" +grep -Fq -- 'artifact_result=within-budget' "$runner" +grep -A3 '^ identity_schema)' "$runner" | grep -Fq -- 'max_len=1048577' +grep -A3 '^ identity_capability)' "$runner" | grep -Fq -- 'max_len=64' +grep -A3 '^ identity_merkle)' "$runner" | grep -Fq -- 'max_len=1048577' +grep -A3 '^ identity_state)' "$runner" | grep -Fq -- 'max_len=64' +grep -A3 '^ identity_pairing)' "$runner" | grep -Fq -- 'max_len=262145' +grep -A3 '^ identity_sync)' "$runner" | grep -Fq -- 'max_len=4194305' +grep -A3 '^ identity_provider)' "$runner" | grep -Fq -- 'max_len=4096' +grep -A3 '^ identity_semantics)' "$runner" | grep -Fq -- 'max_len=8209' grep -Fq -- 'fuzz_toolchain="${KRIKOS_FUZZ_TOOLCHAIN:-nightly-2026-07-19}"' "$runner" grep -Fq -- 'fuzz_target=$(rustc "+$fuzz_toolchain" -vV' "$runner" grep -Fq -- 'cargo "+$fuzz_toolchain" fuzz run' "$runner" @@ -141,4 +242,592 @@ if grep -Eq '^[[:space:]]+pull_request:' "$scheduled_workflow"; then exit 1 fi +# Exercise the runner with fake cargo/rustc/df/du commands. No fuzz binary is built or run. +fixture_root=$(mktemp -d) +: > "$fixture_root/.fuzz-tooling-fixture" +mkdir -p "$fixture_root/bin" "$fixture_root/tmp" "$fixture_root/artifacts" + +cat > "$fixture_root/bin/rustc" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' 'rustc 1.91.0 (fixture)' 'host: x86_64-unknown-linux-gnu' +EOF + +cat > "$fixture_root/bin/df" <<'EOF' +#!/usr/bin/env bash +path="${!#}" +if [[ "${MOCK_DF_MODE:-}" == low \ + || ( -n "${MOCK_DF_LOW_PATH:-}" && "$path" == "$MOCK_DF_LOW_PATH" ) \ + || ( -f "${MOCK_DF_RUNTIME_MARKER:-/nonexistent}" \ + && -n "${MOCK_DF_RUNTIME_LOW_PATH:-}" \ + && "$path" == "$MOCK_DF_RUNTIME_LOW_PATH" ) ]]; then + printf '%s\n' \ + 'Filesystem 1024-blocks Used Available Capacity Mounted on' \ + '/dev/mock 1024 1023 1 100% /mock' +else + exec /usr/bin/df "$@" +fi +EOF + +cat > "$fixture_root/bin/du" <<'EOF' +#!/usr/bin/env bash +path="${!#}" +if [[ -n "${MOCK_DU_RACE_PATH:-}" \ + && "$path" == "$MOCK_DU_RACE_PATH" \ + && ! -e "${MOCK_DU_RACE_MARKER:?}" ]]; then + : > "$MOCK_DU_RACE_MARKER" + printf "du: cannot access '%s/transient-entry': No such file or directory\n" \ + "$path" >&2 + exit 1 +elif [[ "${MOCK_SOURCE_CORPUS_OVER_BUDGET:-}" == 1 \ + && "$path" == */fuzz/corpus/doh_extract ]]; then + printf '268435457\t%s\n' "$path" +elif [[ "${MOCK_ARTIFACT_NEAR_LIMIT_MODE:-}" == summary \ + && "$path" == "${MOCK_ARTIFACT_ROOT:-/nonexistent}" \ + && -f "$path/doh_extract/fuzz-output.txt" ]]; then + printf '67108860\t%s\n' "$path" +elif [[ -f "${MOCK_RUNTIME_MARKER:-/nonexistent}" \ + && -f "${MOCK_RUNTIME_PATH_FILE:-/nonexistent}" \ + && "$path" == "$(<"$MOCK_RUNTIME_PATH_FILE")" ]]; then + printf '268435457\t%s\n' "$path" +else + exec /usr/bin/du "$@" +fi +EOF + +cat > "$fixture_root/bin/find" <<'EOF' +#!/usr/bin/env bash +path="${1:-}" +if [[ -n "${MOCK_FIND_RACE_PATH:-}" && "$path" == "$MOCK_FIND_RACE_PATH" ]]; then + saw_ignore=0 + for argument in "$@"; do + [[ "$argument" == -ignore_readdir_race ]] && saw_ignore=1 + done + if (( saw_ignore == 0 )); then + printf "find: '%s/transient-entry': No such file or directory\n" "$path" >&2 + exit 1 + fi + if [[ -n "${MOCK_FIND_RACE_MARKER:-}" && ! -e "$MOCK_FIND_RACE_MARKER" ]]; then + : > "$MOCK_FIND_RACE_MARKER" + printf "find: '%s/transient-entry': No such file or directory\n" "$path" >&2 + /usr/bin/find "$@" + exit 1 + fi + if [[ "${MOCK_FIND_RACE_ALWAYS:-}" == 1 ]]; then + printf "find: '%s/transient-entry': No such file or directory\n" "$path" >&2 + /usr/bin/find "$@" + exit 1 + fi +fi +exec /usr/bin/find "$@" +EOF + +cat > "$fixture_root/bin/cp" <<'EOF' +#!/usr/bin/env bash +destination="${!#}" +if [[ -n "${MOCK_STAGE_DEST_FILE:-}" \ + && "$(basename -- "$destination")" == .krikos-fuzz-stage.* ]]; then + printf '%s\n' "$destination" > "$MOCK_STAGE_DEST_FILE" +fi +exec /usr/bin/cp "$@" +EOF + +cat > "$fixture_root/bin/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == fuzz && "${2:-}" == --help ]]; then + exit 0 +fi +if [[ "${2:-}" != fuzz || "${3:-}" != run ]]; then + printf 'unexpected fake cargo invocation: %s\n' "$*" >&2 + exit 2 +fi +printf '%s\n' started > "${MOCK_CARGO_STARTED:?}" +run_corpus="${7:?missing mutable corpus}" +case "${MOCK_FUZZ_MODE:-clean}" in + clean) + printf '%s\n' "${CARGO_TARGET_DIR:-}" > "${MOCK_CARGO_TARGET_DIR_FILE:?}" + printf '%s\n' \ + 'stat::number_of_executed_units: 17' \ + 'stat::peak_rss_mb: 23' + ;; + grow-corpus) + printf '%s\n' "$run_corpus" > "${MOCK_RUNTIME_PATH_FILE:?}" + : > "${MOCK_RUNTIME_MARKER:?}" + sleep 30 & + sleep_pid=$! + printf '%s\n' "$sleep_pid" > "${MOCK_SLEEP_PID_FILE:?}" + wait "$sleep_pid" + ;; + exhaust-filesystem) + : > "${MOCK_DF_RUNTIME_MARKER:?}" + sleep 30 & + sleep_pid=$! + printf '%s\n' "$sleep_pid" > "${MOCK_SLEEP_PID_FILE:?}" + wait "$sleep_pid" + ;; + symlink-corpus) + ln -s "${MOCK_SYMLINK_OUTSIDE:?}" "$run_corpus/symlink-escape" + sleep 30 & + sleep_pid=$! + printf '%s\n' "$sleep_pid" > "${MOCK_SLEEP_PID_FILE:?}" + wait "$sleep_pid" + ;; + block) + sleep 30 & + sleep_pid=$! + printf '%s\n' "$sleep_pid" > "${MOCK_SLEEP_PID_FILE:?}" + wait "$sleep_pid" + ;; + orphan-descendant) + sleep 30 /dev/null 2>&1 & + sleep_pid=$! + printf '%s\n' "$sleep_pid" > "${MOCK_SLEEP_PID_FILE:?}" + printf '%s\n' \ + 'stat::number_of_executed_units: 17' \ + 'stat::peak_rss_mb: 23' + ;; + *) + printf 'unknown fake fuzz mode: %s\n' "$MOCK_FUZZ_MODE" >&2 + exit 2 + ;; +esac +EOF +chmod +x "$fixture_root/bin/"* + +common_fixture_env=( + "PATH=$fixture_root/bin:/usr/bin:/bin" + "TMPDIR=$fixture_root/tmp" + "MOCK_CARGO_STARTED=$fixture_root/cargo-started" + "MOCK_RUNTIME_MARKER=$fixture_root/runtime-over-budget" + "MOCK_RUNTIME_PATH_FILE=$fixture_root/runtime-path" + "MOCK_SLEEP_PID_FILE=$fixture_root/fake-sleep-pid" + "MOCK_DF_RUNTIME_MARKER=$fixture_root/filesystem-exhausted" + "MOCK_DU_RACE_MARKER=$fixture_root/du-race-observed" + "MOCK_CARGO_TARGET_DIR_FILE=$fixture_root/cargo-target-dir" + "MOCK_SYMLINK_OUTSIDE=$fixture_root/outside-artifacts" + "MOCK_STAGE_DEST_FILE=$fixture_root/stage-destination" +) + +assert_fake_child_stopped() { + local child_pid + child_pid=$(<"$fixture_root/fake-sleep-pid") + [[ "$child_pid" =~ ^[0-9]+$ ]] || { + printf 'fake campaign recorded an invalid child PID: %s\n' "$child_pid" >&2 + exit 1 + } + for _ in $(seq 1 100); do + if ! kill -0 "$child_pid" 2>/dev/null; then + return 0 + fi + sleep 0.02 + done + printf 'runner leaked fake fuzz child PID: %s\n' "$child_pid" >&2 + exit 1 +} + +outside_artifacts="$fixture_root/outside-artifacts" +mkdir -p "$outside_artifacts" +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts" +ln -s "$outside_artifacts" "$fixture_root/artifacts/doh_extract" +rm -f "$fixture_root/cargo-started" +if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/artifact-target-symlink.log" 2>&1; then + printf '%s\n' 'runner followed a symlinked per-target artifact directory' >&2 + exit 1 +fi +grep -Fq -- 'managed fuzz write tree contains a symlink' \ + "$fixture_root/artifact-target-symlink.log" +[[ ! -e "$fixture_root/cargo-started" ]] || { + printf '%s\n' 'runner started cargo with a symlinked artifact target' >&2 + exit 1 +} + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts/doh_extract" +ln -s "$outside_artifacts/run-summary.txt" \ + "$fixture_root/artifacts/doh_extract/run-summary.txt" +rm -f "$fixture_root/cargo-started" +if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/artifact-file-symlink.log" 2>&1; then + printf '%s\n' 'runner followed a symlinked artifact output file' >&2 + exit 1 +fi +grep -Fq -- 'managed fuzz write tree contains a symlink' \ + "$fixture_root/artifact-file-symlink.log" +[[ ! -e "$fixture_root/cargo-started" ]] || { + printf '%s\n' 'runner started cargo with a symlinked artifact output' >&2 + exit 1 +} + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts/doh_extract" +printf '%s\n' 'must remain intact' > "$outside_artifacts/hardlink-target.txt" +ln "$outside_artifacts/hardlink-target.txt" \ + "$fixture_root/artifacts/doh_extract/run-summary.txt" +rm -f "$fixture_root/cargo-started" +if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/artifact-hardlink.log" 2>&1; then + printf '%s\n' 'runner accepted a multiply-linked artifact output file' >&2 + exit 1 +fi +grep -Fq -- 'managed fuzz artifact tree contains a multiply-linked regular file' \ + "$fixture_root/artifact-hardlink.log" +grep -Fxq -- 'must remain intact' "$outside_artifacts/hardlink-target.txt" +[[ ! -e "$fixture_root/cargo-started" ]] || { + printf '%s\n' 'runner started cargo with a multiply-linked artifact output' >&2 + exit 1 +} + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts/doh_extract" +mkfifo "$fixture_root/artifacts/doh_extract/run-summary.txt" +rm -f "$fixture_root/cargo-started" +started_at=$(date +%s) +if timeout 3 env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/artifact-fifo.log" 2>&1; then + printf '%s\n' 'runner accepted a non-regular artifact output file' >&2 + exit 1 +fi +elapsed=$(( $(date +%s) - started_at )) +(( elapsed < 3 )) || { + printf '%s\n' 'runner blocked on a pre-existing artifact FIFO' >&2 + exit 1 +} +grep -Fq -- 'managed fuzz artifact tree contains a non-regular entry' \ + "$fixture_root/artifact-fifo.log" +[[ ! -e "$fixture_root/cargo-started" ]] || { + printf '%s\n' 'runner started cargo with a non-regular artifact output' >&2 + exit 1 +} + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts/doh_extract" +printf '%s\n' 'previous valid fuzz log' \ + > "$fixture_root/artifacts/doh_extract/fuzz-output.txt" +for padding_index in $(seq 1 63); do + : > "$fixture_root/artifacts/doh_extract/padding-$padding_index" +done +rm -f "$fixture_root/cargo-started" "$fixture_root/stage-destination" +if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/log-retention-limit.log" 2>&1; then + printf '%s\n' 'runner retained a fuzz log without transient file-budget headroom' >&2 + exit 1 +fi +grep -Fq -- 'retained fuzz log would exceed artifact file budget before staging' \ + "$fixture_root/log-retention-limit.log" +grep -Fxq -- 'previous valid fuzz log' \ + "$fixture_root/artifacts/doh_extract/fuzz-output.txt" +if find "$fixture_root/artifacts" -name '.krikos-fuzz-stage.*' -print -quit | grep -q .; then + printf '%s\n' 'rejected fuzz-log retention leaked a staging file' >&2 + exit 1 +fi + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts/doh_extract" +printf '%s\n' 'previous valid summary' \ + > "$fixture_root/artifacts/doh_extract/run-summary.txt" +rm -f "$fixture_root/cargo-started" "$fixture_root/stage-destination" +if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + MOCK_ARTIFACT_NEAR_LIMIT_MODE=summary \ + MOCK_ARTIFACT_ROOT="$fixture_root/artifacts" \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/summary-retention-limit.log" 2>&1; then + printf '%s\n' 'runner retained a summary without transient byte-budget headroom' >&2 + exit 1 +fi +grep -Fq -- 'retained run summary would exceed artifact byte budget before staging' \ + "$fixture_root/summary-retention-limit.log" +grep -Fxq -- 'previous valid summary' \ + "$fixture_root/artifacts/doh_extract/run-summary.txt" +if find "$fixture_root/artifacts" -name '.krikos-fuzz-stage.*' -print -quit | grep -q .; then + printf '%s\n' 'rejected summary retention leaked a staging file' >&2 + exit 1 +fi + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts" +: > "$fixture_root/artifacts/doh_extract" +rm -f "$fixture_root/cargo-started" +if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/artifact-target-file.log" 2>&1; then + printf '%s\n' 'runner accepted a non-directory artifact target root' >&2 + exit 1 +fi +grep -Fq -- 'fuzz artifact target is not a directory' \ + "$fixture_root/artifact-target-file.log" +[[ ! -e "$fixture_root/cargo-started" ]] || { + printf '%s\n' 'runner started cargo with a non-directory artifact target' >&2 + exit 1 +} + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts" +for monitored_root in "$repo_root/fuzz/target" "$repo_root"; do + rm -f "$fixture_root/cargo-started" + if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + MOCK_DF_LOW_PATH="$monitored_root" \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/write-root-preflight.log" 2>&1; then + printf 'runner ignored the write/build filesystem preflight: %s\n' \ + "$monitored_root" >&2 + exit 1 + fi + grep -Fq -- 'insufficient free space for bounded fuzz run' \ + "$fixture_root/write-root-preflight.log" + [[ ! -e "$fixture_root/cargo-started" ]] || { + printf 'runner started cargo after write/build preflight failure: %s\n' \ + "$monitored_root" >&2 + exit 1 + } +done + +rm -f "$fixture_root/cargo-started" +if env "${common_fixture_env[@]}" MOCK_DF_MODE=low \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/preflight.log" 2>&1; then + printf '%s\n' 'runner ignored the free-space preflight' >&2 + exit 1 +fi +grep -Fq -- 'insufficient free space for bounded fuzz run' "$fixture_root/preflight.log" +[[ ! -e "$fixture_root/cargo-started" ]] || { + printf '%s\n' 'runner started cargo after free-space preflight failure' >&2 + exit 1 +} + +rm -f "$fixture_root/cargo-started" +if env "${common_fixture_env[@]}" MOCK_SOURCE_CORPUS_OVER_BUDGET=1 \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/source-corpus-preflight.log" 2>&1; then + printf '%s\n' 'runner copied an oversized reviewed corpus' >&2 + exit 1 +fi +grep -Fq -- \ + 'mutable corpus budget exceeded before reviewed-corpus copy' \ + "$fixture_root/source-corpus-preflight.log" +[[ ! -e "$fixture_root/cargo-started" ]] || { + printf '%s\n' 'runner started cargo after reviewed-corpus preflight failure' >&2 + exit 1 +} +if find "$fixture_root/tmp" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then + printf '%s\n' 'reviewed-corpus preflight created a temporary copy' >&2 + exit 1 +fi + +rm -f "$fixture_root/cargo-started" "$fixture_root/runtime-over-budget" \ + "$fixture_root/runtime-path" "$fixture_root/fake-sleep-pid" +started_at=$(date +%s) +if timeout 8 env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=grow-corpus \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/watchdog.log" 2>&1; then + printf '%s\n' 'runner ignored mutable-corpus growth during execution' >&2 + exit 1 +fi +elapsed=$(( $(date +%s) - started_at )) +(( elapsed < 8 )) || { + printf '%s\n' 'runtime budget watchdog did not terminate the fake campaign promptly' >&2 + exit 1 +} +grep -Fq -- 'mutable corpus budget exceeded during execution' "$fixture_root/watchdog.log" +assert_fake_child_stopped +if find "$fixture_root/tmp" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then + printf '%s\n' 'runtime budget failure leaked its mutable corpus directory' >&2 + exit 1 +fi + +for monitored_root in "$repo_root/fuzz/target" "$repo_root"; do + rm -f "$fixture_root/cargo-started" "$fixture_root/filesystem-exhausted" \ + "$fixture_root/fake-sleep-pid" + started_at=$(date +%s) + if timeout 8 env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=exhaust-filesystem \ + MOCK_DF_RUNTIME_LOW_PATH="$monitored_root" \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/filesystem-watchdog.log" 2>&1; then + printf 'runner ignored write/build filesystem exhaustion: %s\n' \ + "$monitored_root" >&2 + exit 1 + fi + elapsed=$(( $(date +%s) - started_at )) + (( elapsed < 8 )) || { + printf 'write/build filesystem watchdog was not prompt: %s\n' \ + "$monitored_root" >&2 + exit 1 + } + grep -Fq -- 'free-space headroom exhausted during execution' \ + "$fixture_root/filesystem-watchdog.log" + assert_fake_child_stopped + if find "$fixture_root/tmp" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then + printf 'filesystem budget failure leaked mutable corpus: %s\n' \ + "$monitored_root" >&2 + exit 1 + fi +done + +rm -f "$fixture_root/cargo-started" "$fixture_root/fake-sleep-pid" +started_at=$(date +%s) +if timeout 8 env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=symlink-corpus \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/symlink-watchdog.log" 2>&1; then + printf '%s\n' 'runner ignored a mutable-corpus symlink escape' >&2 + exit 1 +fi +elapsed=$(( $(date +%s) - started_at )) +(( elapsed < 8 )) || { + printf '%s\n' 'symlink watchdog did not terminate the fake campaign promptly' >&2 + exit 1 +} +grep -Fq -- 'managed fuzz write tree contains a symlink' \ + "$fixture_root/symlink-watchdog.log" +assert_fake_child_stopped +if find "$fixture_root/tmp" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then + printf '%s\n' 'symlink budget failure leaked its mutable corpus directory' >&2 + exit 1 +fi + +rm -f "$fixture_root/cargo-started" "$fixture_root/runtime-over-budget" \ + "$fixture_root/runtime-path" "$fixture_root/fake-sleep-pid" +started_at=$(date +%s) +if timeout 8 env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=orphan-descendant \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/orphan-descendant.log" 2>&1; then + printf '%s\n' 'runner accepted a live process-group descendant after campaign leader exit' >&2 + exit 1 +fi +elapsed=$(( $(date +%s) - started_at )) +(( elapsed < 8 )) || { + printf '%s\n' 'runner did not terminate an orphaned campaign descendant promptly' >&2 + exit 1 +} +grep -Fq -- 'fuzz campaign left a live process-group descendant after leader exit' \ + "$fixture_root/orphan-descendant.log" +assert_fake_child_stopped +if find "$fixture_root/tmp" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then + printf '%s\n' 'orphan-descendant failure leaked its mutable corpus directory' >&2 + exit 1 +fi + +rm -f "$fixture_root/cargo-started" "$fixture_root/runtime-over-budget" \ + "$fixture_root/runtime-path" "$fixture_root/fake-sleep-pid" +env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=block \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/signal.log" 2>&1 & +runner_pid=$! +for _ in $(seq 1 100); do + [[ -e "$fixture_root/cargo-started" && -e "$fixture_root/fake-sleep-pid" ]] && break + sleep 0.02 +done +[[ -e "$fixture_root/cargo-started" && -e "$fixture_root/fake-sleep-pid" ]] || { + printf '%s\n' 'fake campaign did not start for signal cleanup test' >&2 + kill -TERM "$runner_pid" 2>/dev/null || true + wait "$runner_pid" 2>/dev/null || true + exit 1 +} +kill -TERM "$runner_pid" +if wait "$runner_pid"; then + printf '%s\n' 'runner reported success after SIGTERM' >&2 + exit 1 +fi +assert_fake_child_stopped +if find "$fixture_root/tmp" -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then + printf '%s\n' 'SIGTERM leaked the exact mutable corpus directory' >&2 + exit 1 +fi + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts" +rm -f "$fixture_root/du-race-observed" "$fixture_root/cargo-started" +if ! env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + MOCK_DU_RACE_PATH="$fixture_root/artifacts" \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/du-race.log" 2>&1; then + printf '%s\n' 'runner treated one disappearing du entry as a permanent traversal failure' >&2 + cat "$fixture_root/du-race.log" >&2 + exit 1 +fi +[[ -f "$fixture_root/du-race-observed" ]] || { + printf '%s\n' 'du race fixture did not exercise the disappearing-entry path' >&2 + exit 1 +} + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts" +rm -f "$fixture_root/cargo-started" "$fixture_root/find-race-observed" +if ! env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + MOCK_FIND_RACE_PATH="$repo_root/fuzz/target" \ + MOCK_FIND_RACE_MARKER="$fixture_root/find-race-observed" \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/find-race.log" 2>&1; then + printf '%s\n' 'runner treated a disappearing find entry as a permanent traversal failure' >&2 + cat "$fixture_root/find-race.log" >&2 + exit 1 +fi +[[ -f "$fixture_root/find-race-observed" ]] || { + printf '%s\n' 'find race fixture did not exercise the disappearing-entry path' >&2 + exit 1 +} + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts" +rm -f "$fixture_root/cargo-started" +if env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + MOCK_FIND_RACE_PATH="$repo_root/fuzz/target" \ + MOCK_FIND_RACE_ALWAYS=1 \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/persistent-find-race.log" 2>&1; then + printf '%s\n' 'runner accepted a persistently incomplete find traversal' >&2 + exit 1 +fi +grep -Fq -- 'could not inspect managed fuzz tree before setup' \ + "$fixture_root/persistent-find-race.log" +if [[ -f "$fixture_root/cargo-started" ]]; then + printf '%s\n' 'runner started cargo after persistently incomplete find traversals' >&2 + exit 1 +fi + +rm -rf -- "$fixture_root/artifacts" +mkdir -p "$fixture_root/artifacts" +rm -f "$fixture_root/cargo-started" "$fixture_root/runtime-over-budget" \ + "$fixture_root/runtime-path" "$fixture_root/fake-sleep-pid" \ + "$fixture_root/cargo-target-dir" "$fixture_root/stage-destination" +env "${common_fixture_env[@]}" MOCK_FUZZ_MODE=clean \ + "$runner" --seconds 1 --target doh_extract --artifacts "$fixture_root/artifacts" \ + >"$fixture_root/clean.log" 2>&1 +summary="$fixture_root/artifacts/doh_extract/run-summary.txt" +grep -Fq -- 'toolchain=nightly-2026-07-19' "$summary" +grep -Fq -- 'executed_units=17' "$summary" +grep -Fq -- 'peak_rss_mb=23' "$summary" +grep -Eq -- '^wall_seconds=[0-9]+$' "$summary" +grep -Fq -- 'command=' "$summary" +grep -Fq -- 'mutable_corpus_files=' "$summary" +grep -Fq -- 'mutable_corpus_bytes=' "$summary" +grep -Fq -- 'corpus_result=within-budget' "$summary" +grep -Fq -- 'artifact_files=' "$summary" +grep -Fq -- 'artifact_bytes=' "$summary" +grep -Fq -- 'artifact_result=within-budget' "$summary" +[[ "$(<"$fixture_root/cargo-target-dir")" == "$repo_root/fuzz/target" ]] || { + printf '%s\n' 'runner did not pin the monitored fuzz build target directory' >&2 + exit 1 +} +[[ -f "$fixture_root/stage-destination" ]] || { + printf '%s\n' 'runner did not stage retained output on the artifact filesystem' >&2 + exit 1 +} +case "$(<"$fixture_root/stage-destination")" in + "$fixture_root/artifacts/doh_extract/".krikos-fuzz-stage.*) ;; + *) + printf '%s\n' 'runner staged retained output outside the artifact target directory' >&2 + exit 1 + ;; +esac +if find "$fixture_root/artifacts" -name '.krikos-fuzz-stage.*' -print -quit | grep -q .; then + printf '%s\n' 'successful retention leaked a staging file' >&2 + exit 1 +fi + printf '%s\n' 'bounded fuzz tooling contract passed' diff --git a/scripts/tests/check-identity-os-rng-boundary.sh b/scripts/tests/check-identity-os-rng-boundary.sh new file mode 100755 index 00000000000..bec527e0a0f --- /dev/null +++ b/scripts/tests/check-identity-os-rng-boundary.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +cd "$repo_root" + +python3 - <<'PY' +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + + +manifest_path = Path("protocols/krikos-identity/Cargo.toml") +source_root = Path("protocols/krikos-identity/src") +manifest = tomllib.loads(manifest_path.read_text(encoding="utf-8")) +base_manifest_path = Path("krikos-base/Cargo.toml") +base_source_root = Path("krikos-base/src") +base_manifest = tomllib.loads(base_manifest_path.read_text(encoding="utf-8")) +failures: list[str] = [] + + +def fail(message: str) -> None: + failures.append(message) + + +dependencies = manifest.get("dependencies", {}) +getrandom = dependencies.get("getrandom") +if not isinstance(getrandom, dict): + fail("getrandom must be an explicit dependency table") +else: + if getrandom.get("optional") is not True: + fail("getrandom must be optional") + if getrandom.get("default-features") is not False: + fail("getrandom default features must remain disabled") + +features = manifest.get("features", {}) +if features.get("default") != []: + fail("krikos-identity default features must remain empty") +if features.get("os-rng") != ["dep:getrandom"]: + fail("os-rng must enable exactly dep:getrandom") +for feature_name, feature_members in features.items(): + if feature_name == "os-rng" or not isinstance(feature_members, list): + continue + if "dep:getrandom" in feature_members or "getrandom" in feature_members: + fail(f"{feature_name} must not enable getrandom") + +base_dependency = dependencies.get("krikos-base") +if not isinstance(base_dependency, dict): + fail("krikos-base must be an explicit dependency table") +else: + if base_dependency.get("default-features") is not False: + fail("krikos-identity must disable krikos-base default features") + if base_dependency.get("features") != ["key-types"]: + fail("krikos-identity default core must enable exactly krikos-base/key-types") + +base_dependencies = base_manifest.get("dependencies", {}) +base_rand = base_dependencies.get("rand") +if not isinstance(base_rand, dict) or base_rand.get("optional") is not True: + fail("krikos-base rand dependency must remain optional") +base_target_dependencies = base_manifest.get("target", {}) +base_getrandom_tables = [ + target_table.get("dependencies", {}).get("getrandom") + for target_table in base_target_dependencies.values() + if isinstance(target_table, dict) +] +if len(base_getrandom_tables) != 1 or not isinstance(base_getrandom_tables[0], dict): + fail("krikos-base must declare exactly one target-specific getrandom dependency") +elif base_getrandom_tables[0].get("optional") is not True: + fail("krikos-base target-specific getrandom dependency must remain optional") + +base_features = base_manifest.get("features", {}) +if base_features.get("key") != ["os-rng"]: + fail("krikos-base/key must remain the backward-compatible os-rng aggregate") +os_rng_members = base_features.get("os-rng") +if not isinstance(os_rng_members, list) or set(os_rng_members) != { + "key-types", + "dep:getrandom", + "dep:rand", +}: + fail("krikos-base/os-rng must enable key types plus only rand and getrandom") + + +def feature_closure(feature_name: str) -> set[str]: + pending = [feature_name] + visited: set[str] = set() + members: set[str] = set() + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + current_members = base_features.get(current) + if not isinstance(current_members, list): + fail(f"krikos-base feature {current} must exist and be a list") + continue + for member in current_members: + members.add(member) + if not member.startswith("dep:"): + pending.append(member) + return members + + +key_type_members = feature_closure("key-types") +ambient_base_members = {"key", "os-rng", "dep:getrandom", "dep:rand"} +leaked_base_members = key_type_members.intersection(ambient_base_members) +if leaked_base_members: + fail( + "krikos-base/key-types must not enable ambient entropy members " + f"{sorted(leaked_base_members)}" + ) + + +OS_CFG = '#[cfg(feature = "os-rng")]' +OS_DOC_CFG = '#[cfg_attr(krikos_docsrs, doc(cfg(feature = "os-rng")))]' +FUNCTION_RE = re.compile(r"\bfn\s+([A-Za-z_][A-Za-z0-9_]*)") + + +def function_lines(path: Path, function_name: str) -> list[int]: + pattern = re.compile(rf"\bfn\s+{re.escape(function_name)}\b") + return [ + index + for index, line in enumerate(path.read_text(encoding="utf-8").splitlines()) + if pattern.search(line) + ] + + +def require_os_cfg(path: Path, line_index: int, *, public: bool) -> None: + lines = path.read_text(encoding="utf-8").splitlines() + expected = [OS_CFG, OS_DOC_CFG] if public else [OS_CFG] + actual = [line.strip() for line in lines[line_index - len(expected) : line_index]] + if actual != expected: + line_number = line_index + 1 + fail(f"{path}:{line_number} must be immediately gated by {expected!r}") + + +ambient_apis: dict[Path, dict[str, tuple[int, bool]]] = { + source_root / "key_wrap.rs": {"rotate_group_key": (1, True)}, + source_root / "pairing.rs": { + "issue": (1, True), + "generate": (1, True), + }, + source_root / "privacy.rs": { + "seal": (2, True), + "generate": (3, True), + "os_secret": (1, False), + }, +} +for path, names in ambient_apis.items(): + for function_name, (expected_count, public) in names.items(): + matches = function_lines(path, function_name) + if len(matches) != expected_count: + fail( + f"{path}: expected {expected_count} ambient {function_name} definition(s), " + f"found {len(matches)}" + ) + continue + for line_index in matches: + require_os_cfg(path, line_index, public=public) + + +default_rng_apis: dict[Path, dict[str, int]] = { + source_root / "key_wrap.rs": {"rotate_group_key_with_rng": 1}, + source_root / "pairing.rs": {"issue_with_rng": 1, "generate_with_rng": 1}, + source_root / "privacy.rs": {"seal_with_rng": 2, "generate_with_rng": 3}, +} +for path, names in default_rng_apis.items(): + lines = path.read_text(encoding="utf-8").splitlines() + for function_name, expected_count in names.items(): + matches = function_lines(path, function_name) + if len(matches) != expected_count: + fail( + f"{path}: expected {expected_count} explicit-RNG {function_name} definition(s), " + f"found {len(matches)}" + ) + continue + for line_index in matches: + prior_lines = [line.strip() for line in lines[max(0, line_index - 3) : line_index]] + if OS_CFG in prior_lines: + fail(f"{path}:{line_index + 1} explicit-RNG API must remain in the default core") + + +for path in source_root.rglob("*.rs"): + lines = path.read_text(encoding="utf-8").splitlines() + for call_index, line in enumerate(lines): + if "getrandom::" not in line: + continue + function_index = None + for candidate_index in range(call_index, -1, -1): + if FUNCTION_RE.search(lines[candidate_index]): + function_index = candidate_index + break + if function_index is None: + fail(f"{path}:{call_index + 1} getrandom call is outside a function") + continue + prior_lines = [ + candidate.strip() + for candidate in lines[max(0, function_index - 3) : function_index] + ] + if OS_CFG not in prior_lines: + fail( + f"{path}:{call_index + 1} getrandom call is not inside an os-rng-gated function" + ) + +base_key_path = base_source_root / "key.rs" +base_generate_lines = function_lines(base_key_path, "generate") +if len(base_generate_lines) != 1: + fail(f"{base_key_path}: expected one SecretKey::generate definition") +else: + require_os_cfg(base_key_path, base_generate_lines[0], public=True) + +base_key_lines = base_key_path.read_text(encoding="utf-8").splitlines() +for call_index, line in enumerate(base_key_lines): + stripped = line.strip() + if stripped.startswith("//") or not any( + ambient_call in stripped for ambient_call in ("rand::random", "rand::rng(", "getrandom::") + ): + continue + function_index = None + for candidate_index in range(call_index, -1, -1): + if FUNCTION_RE.search(base_key_lines[candidate_index]): + function_index = candidate_index + break + if function_index is None: + fail(f"{base_key_path}:{call_index + 1} ambient RNG call is outside a function") + continue + prior_lines = [ + candidate.strip() + for candidate in base_key_lines[max(0, function_index - 3) : function_index] + ] + if OS_CFG not in prior_lines: + fail(f"{base_key_path}:{call_index + 1} ambient RNG call is not os-rng-gated") + +base_lib_text = (base_source_root / "lib.rs").read_text(encoding="utf-8") +if base_lib_text.count('#[cfg(feature = "key-types")]') != 4: + fail("krikos-base key modules and exports must be gated by key-types") +if '#[cfg(feature = "key")]' in base_lib_text: + fail("krikos-base key modules must not require the ambient-RNG compatibility aggregate") + + +lib_path = source_root / "lib.rs" +lib_lines = lib_path.read_text(encoding="utf-8").splitlines() +export_line = "pub use key_wrap::rotate_group_key;" +if export_line not in lib_lines: + fail("lib.rs must expose rotate_group_key through a dedicated feature-gated re-export") +else: + require_os_cfg(lib_path, lib_lines.index(export_line), public=True) + +lib_text = "\n".join(lib_lines) +readme_text = Path("protocols/krikos-identity/README.md").read_text(encoding="utf-8") +if "//! - `os-rng`" not in lib_text: + fail("crate API feature list must document os-rng") +if "`os-rng`" not in readme_text: + fail("crate README feature list must document os-rng") + + +if failures: + for failure in failures: + print(f"identity OS RNG boundary: {failure}", file=sys.stderr) + raise SystemExit(1) + +print("identity OS RNG boundary passed") +PY diff --git a/scripts/tests/check-identity-release-gate.sh b/scripts/tests/check-identity-release-gate.sh new file mode 100755 index 00000000000..8dc17779dc6 --- /dev/null +++ b/scripts/tests/check-identity-release-gate.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + +fail() { + printf 'identity release-gate contract: %s\n' "$1" >&2 + exit 1 +} + +require_file() { + local path=$1 + [[ -f "$repo_root/$path" ]] || fail "required file is missing: $path" +} + +require_text() { + local path=$1 + local expected=$2 + grep -Fq -- "$expected" "$repo_root/$path" || + fail "$path is missing required contract: $expected" +} + +for path in \ + protocols/krikos-identity/release-gate.toml \ + protocols/krikos-identity/docs/release-gate.md \ + scripts/check-identity-release-gate.py; do + require_file "$path" +done + +for approval in \ + third_party_security_audit \ + independently_maintained_interoperability \ + production_provider_diversity \ + protocol_governance \ + public_api_semver_baseline \ + persistent_schema_support; do + require_text protocols/krikos-identity/release-gate.toml "$approval = false" +done + +gate_command='python3 scripts/check-identity-release-gate.py --expect-closed' +require_text .github/workflows/ci.yml "$gate_command" +require_text .github/workflows/release.yml "$gate_command" +require_text scripts/test-local-first-framework.sh "$gate_command" +require_text Makefile.toml '[tasks.identity-release-gate]' +require_text Makefile.toml 'args = ["scripts/check-identity-release-gate.py", "--expect-closed"]' +require_text scripts/tests/check-v2-release-readiness.sh 'protocols/krikos-identity/release-gate.toml' +require_text scripts/tests/check-v2-release-readiness.sh "$gate_command" +require_text docs/release/v2-release-checklist.md 'protocols/krikos-identity/release-gate.toml' +require_text protocols/krikos-identity/README.md 'docs/release-gate.md' +require_text docs/README.md 'Identity stable-release gate' + +python3 - "$repo_root" <<'PY' +import sys +import tomllib +from pathlib import Path + +root = Path(sys.argv[1]) +with (root / "framework/release-gate.toml").open("rb") as source: + policy = tomllib.load(source) + +expected = [ + {"order": 1, "name": "krikos-blobs", "path": "protocols/krikos-blobs"}, + {"order": 2, "name": "krikos-gossip", "path": "protocols/krikos-gossip"}, + {"order": 3, "name": "krikos-docs", "path": "protocols/krikos-docs"}, + {"order": 4, "name": "krikos-app", "path": "framework/app"}, +] +if policy.get("packages") != expected: + raise SystemExit("identity gate must not alter the exact four-package framework gate") +PY + +python3 "$repo_root/scripts/check-identity-release-gate.py" --expect-closed +open_output=$(mktemp) +fixture_root=$(mktemp -d) +trap 'rm -f "$open_output"; rm -rf "$fixture_root"' EXIT + +if python3 "$repo_root/scripts/check-identity-release-gate.py" --require-open \ + >"$open_output" 2>&1; then + fail 'identity stable-release gate unexpectedly opened' +fi +for blocker in \ + 'third-party security audit is not approved' \ + 'independently maintained interoperability is not approved' \ + 'production provider diversity is not approved' \ + 'protocol governance is not approved' \ + 'public API and SemVer baseline is not approved' \ + 'persistent-schema support is not approved'; do + grep -Fq -- "$blocker" "$open_output" || fail "missing closed-gate reason: $blocker" +done + +seed_fixture() { + local name=$1 + local fixture="$fixture_root/$name" + mkdir -p \ + "$fixture/protocols/krikos-identity" \ + "$fixture/scripts" + cp "$repo_root/Cargo.toml" "$fixture/Cargo.toml" + cp "$repo_root/Makefile.toml" "$fixture/Makefile.toml" + cp "$repo_root/scripts/verify-release-packages.sh" "$fixture/scripts/verify-release-packages.sh" + cp "$repo_root/protocols/krikos-identity/Cargo.toml" \ + "$fixture/protocols/krikos-identity/Cargo.toml" + cp "$repo_root/protocols/krikos-identity/release-gate.toml" \ + "$fixture/protocols/krikos-identity/release-gate.toml" + printf '%s\n' "$fixture" +} + +expect_fixture_failure() { + local fixture=$1 + local expected=$2 + local output="$fixture/check-output" + if python3 "$repo_root/scripts/check-identity-release-gate.py" \ + --repo-root "$fixture" --expect-closed >"$output" 2>&1; then + fail "tampered fixture unexpectedly passed: $expected" + fi + grep -Fq -- "$expected" "$output" || + fail "tampered fixture did not report expected failure: $expected" +} + +expect_open_fixture_failure() { + local fixture=$1 + local expected=$2 + local output="$fixture/check-output" + if python3 "$repo_root/scripts/check-identity-release-gate.py" \ + --repo-root "$fixture" --require-open >"$output" 2>&1; then + fail "unpublishable open fixture unexpectedly passed: $expected" + fi + grep -Fq -- "$expected" "$output" || + fail "unpublishable open fixture did not report expected failure: $expected" +} + +fixture=$(seed_fixture publish-enabled) +sed -i 's/^publish = false$/publish = true/' \ + "$fixture/protocols/krikos-identity/Cargo.toml" +expect_fixture_failure "$fixture" 'krikos-identity must retain publish = false while the gate is closed' + +fixture=$(seed_fixture workspace-removed) +sed -i '\|"protocols/krikos-identity",|d' "$fixture/Cargo.toml" +expect_fixture_failure "$fixture" 'protocols/krikos-identity is not a root workspace member' + +fixture=$(seed_fixture release-order-added) +sed -i 's/^packages="/packages="krikos-identity /' \ + "$fixture/scripts/verify-release-packages.sh" +expect_fixture_failure "$fixture" \ + 'krikos-identity entered the publishable release order while its gate is closed' + +fixture=$(seed_fixture external-types-enabled) +sed -i '\|"protocols/krikos-identity",|d' "$fixture/Makefile.toml" +expect_fixture_failure "$fixture" \ + 'protocols/krikos-identity must remain outside the external-types baseline while the gate is closed' + +fixture=$(seed_fixture empty-publish-allowlist) +sed -i 's/^status = "blocked"$/status = "open"/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/ = false$/ = true/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/ = \[\]$/ = ["reviewed-evidence"]/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/^publish = false$/publish = []/' \ + "$fixture/protocols/krikos-identity/Cargo.toml" +sed -i '\|"protocols/krikos-identity",|d' "$fixture/Makefile.toml" +sed -i 's/^packages="/packages="krikos-identity /' \ + "$fixture/scripts/verify-release-packages.sh" +expect_open_fixture_failure "$fixture" \ + 'krikos-identity publish setting is not open for the stable registry' + +fixture=$(seed_fixture wrong-release-order) +sed -i 's/^status = "blocked"$/status = "open"/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/ = false$/ = true/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/ = \[\]$/ = ["reviewed-evidence"]/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/^publish = false$/publish = true/' \ + "$fixture/protocols/krikos-identity/Cargo.toml" +sed -i '\|"protocols/krikos-identity",|d' "$fixture/Makefile.toml" +sed -i 's/^packages="/packages="krikos-identity /' \ + "$fixture/scripts/verify-release-packages.sh" +expect_open_fixture_failure "$fixture" \ + 'krikos-identity must follow krikos in the publishable release order' + +fixture=$(seed_fixture coordinated-open) +sed -i 's/^status = "blocked"$/status = "open"/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/ = false$/ = true/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/ = \[\]$/ = ["reviewed-evidence"]/' \ + "$fixture/protocols/krikos-identity/release-gate.toml" +sed -i 's/^publish = false$/publish = true/' \ + "$fixture/protocols/krikos-identity/Cargo.toml" +sed -i '\|"protocols/krikos-identity",|d' "$fixture/Makefile.toml" +sed -i 's/ krikos krikos-dns-server/ krikos krikos-identity krikos-dns-server/' \ + "$fixture/scripts/verify-release-packages.sh" +python3 "$repo_root/scripts/check-identity-release-gate.py" \ + --repo-root "$fixture" --require-open + +printf '%s\n' 'identity stable-release gate contract passed' diff --git a/scripts/tests/check-local-first-framework-ci.sh b/scripts/tests/check-local-first-framework-ci.sh index 729a62dcba2..304ec0422d3 100755 --- a/scripts/tests/check-local-first-framework-ci.sh +++ b/scripts/tests/check-local-first-framework-ci.sh @@ -13,8 +13,18 @@ require_text() { fi } +require_line() { + local path=$1 + local pattern=$2 + if ! grep -Eq -- "$pattern" "$repo_root/$path"; then + printf '%s is missing active local-first framework command: %s\n' "$path" "$pattern" >&2 + exit 1 + fi +} + for path in \ framework/release-gate.toml \ + scripts/check-identity-feature-matrix.sh \ scripts/check-framework-release-gate.py \ docs/framework/upstream-sync.md; do [[ -f "$repo_root/$path" ]] || { @@ -24,17 +34,61 @@ for path in \ done require_text .github/workflows/ci.yml 'local_first_framework:' -require_text .github/workflows/ci.yml 'cargo test -p krikos-local-first-app-tests --test two_node' +require_line .github/workflows/ci.yml '^[[:space:]]*MSRV:[[:space:]]+"1\.91\.0"[[:space:]]*$' +require_text .github/workflows/ci.yml 'cargo test --locked -p krikos-app --all-features' +require_text .github/workflows/ci.yml 'cargo test --locked -p krikos-local-first-app-tests --test two_node' require_text .github/workflows/ci.yml 'scripts/tests/check-blobs-v0-interop.sh' require_text .github/workflows/ci.yml 'scripts/tests/check-gossip-v0-interop.sh' -require_text .github/workflows/ci.yml 'cargo test -p krikos-docs migration' +require_text .github/workflows/ci.yml 'cargo test --locked -p krikos-docs migration' require_text .github/workflows/ci.yml 'scripts/check-framework-release-gate.py --expect-closed' +require_text .github/workflows/ci.yml 'scripts/check-identity-interop-vectors.sh' +require_text .github/workflows/ci.yml 'scripts/check-identity-wire-inventory.sh' +require_line .github/workflows/ci.yml '^[[:space:]]*run:[[:space:]]+scripts/check-identity-doc-links\.py[[:space:]]*$' +local_first_job=$(sed -n '/^ local_first_framework:/,/^ fuzz_smoke:/p' \ + "$repo_root/.github/workflows/ci.yml") +if ! grep -Fq -- 'sudo apt-get install --yes ripgrep' <<<"$local_first_job"; then + printf '%s\n' 'local-first framework CI job does not install ripgrep for identity inventory' >&2 + exit 1 +fi +require_text .github/workflows/ci.yml 'RUSTDOCFLAGS: "-Dwarnings --cfg krikos_docsrs"' +require_text .github/workflows/ci.yml 'cargo doc --locked --workspace --all-features --no-deps --document-private-items' +require_text .github/workflows/ci.yml 'cargo "+$MSRV" test --locked -p krikos-identity --no-default-features --all-targets' +require_text .github/workflows/ci.yml 'cargo "+$MSRV" test --locked -p krikos-identity --all-features --all-targets' +require_text .github/workflows/ci.yml 'cargo "+$MSRV" check --locked --workspace --all-targets --all-features' +require_text .github/workflows/ci.yml 'cargo "+$MSRV" check --locked --manifest-path krikos-sim/Cargo.toml --all-targets --all-features' +require_text .github/workflows/ci.yml 'cargo "+$MSRV" clippy --locked -p krikos-identity --no-default-features --all-targets -- -D warnings' +require_text .github/workflows/ci.yml 'cargo "+$MSRV" clippy --locked -p krikos-identity --all-features --all-targets -- -D warnings' +require_text .github/workflows/ci.yml "RUSTDOCFLAGS='-Dwarnings' cargo \"+\$MSRV\" doc --locked -p krikos-identity --all-features --no-deps" +require_text .github/workflows/ci.yml 'cargo "+$MSRV" test --locked -p krikos-identity --no-default-features --doc' +require_text .github/workflows/ci.yml 'cargo "+$MSRV" test --locked -p krikos-identity --all-features --doc' +require_text .github/workflows/docs.yaml 'cargo doc --locked --workspace --all-features --no-deps' +require_text .github/workflows/docs.yaml 'RUSTDOCFLAGS: "-Dwarnings --cfg krikos_docsrs"' +require_line .github/workflows/ci.yml '^[[:space:]]*run:[[:space:]]+scripts/check-identity-feature-matrix\.sh[[:space:]]*$' +require_text .github/workflows/ci.yml 'scripts/check-identity-model.sh' +require_text .github/workflows/ci.yml 'identity corpus-test krikos-sim/identity-corpus' +require_text .github/workflows/ci.yml 'cargo run --locked --manifest-path krikos-sim/Cargo.toml --bin cargo-sim -- identity corpus-test krikos-sim/identity-corpus' require_text .github/workflows/tests.yaml 'krikos-app' require_text .github/workflows/tests.yaml 'krikos-blobs' require_text .github/workflows/tests.yaml 'krikos-docs' require_text .github/workflows/tests.yaml 'krikos-gossip' require_text .github/workflows/release.yml 'scripts/check-framework-release-gate.py --expect-closed' require_text scripts/run-format.sh 'fuzz/Cargo.toml' +require_text scripts/test-local-first-framework.sh 'scripts/check-identity-interop-vectors.sh' +require_text scripts/test-local-first-framework.sh 'scripts/check-identity-wire-inventory.sh' +require_line scripts/test-local-first-framework.sh '^[[:space:]]*scripts/check-identity-doc-links\.py[[:space:]]*$' +require_line scripts/test-local-first-framework.sh '^[[:space:]]*scripts/check-identity-feature-matrix\.sh[[:space:]]*$' +require_text scripts/test-local-first-framework.sh 'scripts/check-identity-model.sh' +require_text scripts/test-local-first-framework.sh 'identity corpus-test krikos-sim/identity-corpus' +require_text scripts/test-local-first-framework.sh 'cargo "+$toolchain" test --locked -p krikos-app --all-features' +require_text scripts/test-local-first-framework.sh 'cargo "+$toolchain" run --locked --manifest-path krikos-sim/Cargo.toml' +require_text scripts/test-local-first-framework.sh 'cargo "+$toolchain" test --locked -p krikos-docs migration' +require_text scripts/test-local-first-framework.sh 'cargo "+$toolchain" test --locked -p krikos-local-first-app-tests --test two_node' +require_text scripts/check-framework-package-layout.sh 'cargo "+$toolchain" package' +require_text scripts/check-identity-interop-vectors.sh '--locked' +require_text scripts/check-identity-wire-inventory.sh '--locked' +require_text scripts/check-identity-model.sh '--locked' +require_text .gitattributes 'protocols/krikos-identity/tests/vectors/*.bin binary linguist-generated' +require_text .gitattributes 'fuzz/corpus/identity_*/* binary linguist-generated' require_text Makefile.toml '[tasks.local-first-framework]' require_text Makefile.toml '[tasks.local-first-release-gate]' require_text docs/release/v2-release-checklist.md 'framework/release-gate.toml' @@ -42,6 +96,8 @@ require_text protocols/krikos-blobs/Cargo.toml 'name = "blobs-transfer"' require_text protocols/krikos-gossip/Cargo.toml 'name = "gossip-setup"' require_text protocols/krikos-docs/Cargo.toml 'name = "docs-setup"' +"$repo_root/scripts/check-identity-feature-matrix.sh" --static-only + python3 "$repo_root/scripts/check-framework-release-gate.py" --expect-closed gate_output=$(mktemp) trap 'rm -f "$gate_output"' EXIT diff --git a/scripts/tests/check-rebrand-unknown-crates.sh b/scripts/tests/check-rebrand-unknown-crates.sh index 2a1be5ba7cf..adeb3662419 100755 --- a/scripts/tests/check-rebrand-unknown-crates.sh +++ b/scripts/tests/check-rebrand-unknown-crates.sh @@ -80,6 +80,11 @@ ALLOWLIST = { "krikos_blobs_docsrs": "krikos-blobs' own per-crate variant of the krikos_docsrs cfg flag above, not a crate", "krikos_loom": "this fork's own tokio-rs/loom-testing cfg flag (Cargo.toml's [lints]), not a crate", "krikos_ref": "a GitHub Actions workflow input/env name (the netsim runner's pinned ref), not a crate", + "krikos-provider-generation-v1": "the redb provider-generation table family name, not a crate", + "krikos-provider-prepared-v1": "the redb provider prepared-append table family name, not a crate", + "krikos-provider-audit-metadata-v2": "the redb provider-audit metadata table name, not a crate", + "krikos-provider-audit-records-v2": "the redb provider-audit record table name, not a crate", + "krikos-provider-audit-v1": "the explicitly rejected legacy redb provider-audit table name, not a crate", } # --- Step 2/3: scan tracked .md/.rs files for backtick/bracket-quoted krikos tokens ------- diff --git a/scripts/tests/check-release-fork-boundary.sh b/scripts/tests/check-release-fork-boundary.sh index 8869a3c66c6..0c54c96118d 100755 --- a/scripts/tests/check-release-fork-boundary.sh +++ b/scripts/tests/check-release-fork-boundary.sh @@ -130,9 +130,9 @@ require_text scripts/krikos-test-env 'scripts/run-all-tests.sh' require_text scripts/run-all-tests.sh 'cargo test --workspace --all-features --tests' require_text scripts/run-all-tests.sh 'cargo test --manifest-path krikos-sim/Cargo.toml --all-features --tests' require_text .github/workflows/ci.yml 'scripts/tests/check-release-fork-boundary.sh' -require_text .github/workflows/ci.yml 'cargo test --manifest-path krikos-sim/Cargo.toml' -require_text .github/workflows/ci.yml 'cargo clippy --manifest-path krikos-sim/Cargo.toml' -require_text .github/workflows/ci.yml 'cargo doc --manifest-path krikos-sim/Cargo.toml' +require_text .github/workflows/ci.yml 'cargo test --locked --manifest-path krikos-sim/Cargo.toml' +require_text .github/workflows/ci.yml 'cargo clippy --locked --manifest-path krikos-sim/Cargo.toml' +require_text .github/workflows/ci.yml 'cargo doc --locked --manifest-path krikos-sim/Cargo.toml' require_text .github/workflows/release.yml 'krikos-noq' require_text .github/workflows/release.yml 'krikos-hickory-server' diff --git a/scripts/tests/check-simulation-gate-workflow.sh b/scripts/tests/check-simulation-gate-workflow.sh index 21d0912dce4..d22f14a8d18 100755 --- a/scripts/tests/check-simulation-gate-workflow.sh +++ b/scripts/tests/check-simulation-gate-workflow.sh @@ -31,6 +31,12 @@ for text in "${required[@]}"; do fi done +contracts_job=$(sed -n '/^ simulation_contracts:/,/^ simulation_gate:/p' "$workflow") +if ! grep -Fq -- 'components: clippy' <<<"$contracts_job"; then + echo "simulation contracts job must install clippy before running its code-quality checks" >&2 + exit 1 +fi + gate_job=$(sed -n '/^ simulation_gate:/,/^ cross_build:/p' "$workflow") if grep -Eq -- '--seeds ["'\'']?[0-9]+\.\.[0-9]+' <<<"$gate_job"; then echo "change gate must not contain fixed exploratory seed ranges" >&2 diff --git a/scripts/tests/check-v2-release-readiness.sh b/scripts/tests/check-v2-release-readiness.sh index cb6a862ca80..410b24f9e39 100755 --- a/scripts/tests/check-v2-release-readiness.sh +++ b/scripts/tests/check-v2-release-readiness.sh @@ -47,10 +47,14 @@ for path in \ scripts/tests/check-v2-semver-policy.sh \ scripts/tests/check-release-fork-boundary.sh \ scripts/tests/check-local-first-framework-ci.sh \ + scripts/tests/check-identity-release-gate.sh \ scripts/check-framework-release-gate.py \ + scripts/check-identity-release-gate.py \ scripts/check-framework-package-layout.sh \ scripts/verify-release-packages.sh \ framework/release-gate.toml \ + protocols/krikos-identity/release-gate.toml \ + protocols/krikos-identity/docs/release-gate.md \ docs/framework/upstream-sync.md \ docs/release/v2-migration.md \ docs/release/v2-release-checklist.md \ @@ -138,6 +142,7 @@ for lock_path in sys.argv[1:]: "krikos-dns-server", "krikos-docs", "krikos-gossip", + "krikos-identity", "krikos-relay", "krikos-runtime", "krikos-resolver", @@ -168,6 +173,19 @@ done require_text scripts/run-bounded-fuzz.sh 'fuzz_toolchain="${KRIKOS_FUZZ_TOOLCHAIN:-nightly-2026-07-19}"' require_text scripts/run-bounded-fuzz.sh 'cargo "+$fuzz_toolchain" fuzz run' +require_text docs/release/v2-release-checklist.md 'All eighteen bounded fuzz smoke targets' +for target in \ + identity_foundation \ + identity_schema \ + identity_capability \ + identity_merkle \ + identity_state \ + identity_pairing \ + identity_sync \ + identity_provider \ + identity_semantics; do + require_text docs/release/v2-release-checklist.md "$target" +done nightly="$repo_root/.github/workflows/simulation-nightly.yml" if grep -Eq '^[[:space:]]+seed: [0-9a-f]{64}[[:space:]]*$' "$nightly"; then @@ -251,9 +269,17 @@ require_text .github/workflows/ci.yml 'scripts/tests/check-release-fork-boundary require_text .github/workflows/ci.yml 'scripts/run-v2-semver-checks.sh' require_text .github/workflows/ci.yml 'scripts/tests/check-v2-semver-policy.sh' require_text .github/workflows/ci.yml 'scripts/tests/check-local-first-framework-ci.sh' +require_text .github/workflows/ci.yml 'python3 scripts/check-identity-release-gate.py --expect-closed' +require_text .github/workflows/ci.yml 'scripts/tests/check-identity-release-gate.sh' +require_text .github/workflows/release.yml 'python3 scripts/check-identity-release-gate.py --expect-closed' +require_text scripts/test-local-first-framework.sh 'python3 scripts/check-identity-release-gate.py --expect-closed' +require_text scripts/check-framework-package-layout.sh ' protocols/krikos-identity' +require_text scripts/reserve-crate-names.sh ' krikos-identity' require_text Makefile.toml '"framework/app"' require_text Makefile.toml '"protocols/krikos-blobs"' require_text Makefile.toml '[tasks.local-first-framework]' +require_text Makefile.toml '[tasks.identity-release-gate]' +require_text Makefile.toml '"protocols/krikos-identity"' require_text .github/workflows/tests.yaml 'runs-on: windows-2022' require_text .github/workflows/tests.yaml 'toolchain: nightly-2026-07-19' postcard_override_count=$(grep -Fc -- 'update -p postcard-derive --precise 0.2.2' \ @@ -286,6 +312,7 @@ if grep -Fq -- 'cargo-semver-checks-action' "$repo_root/.github/workflows/ci.yml fi require_text .github/workflows/release.yml 'scripts/verify-release-packages.sh' require_text .github/workflows/release.yml 'scripts/check-framework-release-gate.py --expect-closed' +require_text docs/release/v2-release-checklist.md 'protocols/krikos-identity/release-gate.toml' require_text scripts/verify-release-packages.sh 'krikos-noq krikos-hickory-server krikos-base krikos-runtime krikos-resolver krikos-dns krikos-relay krikos krikos-dns-server' require_text docs/release/v2-migration.md 'PkarrRelayClient::new' require_text docs/release/v2-migration.md 'krikos_resolver::Builder::build' diff --git a/scripts/workspace-architecture.toml b/scripts/workspace-architecture.toml index 93d3f5baeab..942e6c30fd3 100644 --- a/scripts/workspace-architecture.toml +++ b/scripts/workspace-architecture.toml @@ -105,7 +105,7 @@ name = "krikos-sim" path = "krikos-sim" workspace = "sim" layer = "tooling" -allowed_normal = ["krikos", "krikos-relay", "krikos-runtime"] +allowed_normal = ["krikos", "krikos-base", "krikos-identity", "krikos-relay", "krikos-runtime"] allowed_dev = [] [[packages]] @@ -124,6 +124,14 @@ layer = "protocol" allowed_normal = ["krikos", "krikos-base"] allowed_dev = ["krikos"] +[[packages]] +name = "krikos-identity" +path = "protocols/krikos-identity" +workspace = "root" +layer = "protocol" +allowed_normal = ["krikos", "krikos-base"] +allowed_dev = ["krikos"] + [[packages]] name = "krikos-docs" path = "protocols/krikos-docs" @@ -137,7 +145,7 @@ name = "krikos-app" path = "framework/app" workspace = "root" layer = "framework" -allowed_normal = ["krikos", "krikos-base", "krikos-blobs", "krikos-docs", "krikos-gossip"] +allowed_normal = ["krikos", "krikos-base", "krikos-blobs", "krikos-docs", "krikos-gossip", "krikos-identity"] allowed_dev = [] [[packages]] diff --git a/tools/determinism-checker/src/main.rs b/tools/determinism-checker/src/main.rs index fdf4e8c13df..80d7c1532bc 100644 --- a/tools/determinism-checker/src/main.rs +++ b/tools/determinism-checker/src/main.rs @@ -17,7 +17,7 @@ use syn::{ visit::{self, Visit}, }; -const SOURCE_ROOTS: [&str; 8] = [ +const SOURCE_ROOTS: [&str; 9] = [ "krikos", "krikos-base", "krikos-resolver", @@ -26,12 +26,12 @@ const SOURCE_ROOTS: [&str; 8] = [ "krikos-relay", "krikos-runtime", "krikos-sim", + "protocols/krikos-identity", ]; const MAX_SOURCE_FILES: usize = 20_000; const MAX_SOURCE_BYTES: u64 = 4 * 1024 * 1024; const MAX_OCCURRENCES: usize = 100_000; -// Every entry in SOURCE_ROOTS is a real Cargo package directory (see -// scripts/rename-map.toml, dir_renamed = true), and Cargo requires a `[lib]` +// Every entry in SOURCE_ROOTS is a real Cargo package directory, and Cargo requires a `[lib]` // or `[[bin]]` entry point in at least one `.rs` file for such a package to // build at all -- so zero `.rs` files below an *existing* root is never // legitimate for this specific list, only a symptom of a botched rename @@ -435,6 +435,14 @@ impl<'ast> Visit<'ast> for BoundaryVisitor { fn visit_expr_path(&mut self, node: &'ast ExprPath) { if node.qself.is_none() { let path = self.resolve(&node.path); + // Call expressions record their callee in `visit_expr_call`. A qualified + // path that appears as a value (for example, an entropy function passed + // into a helper) has no call node of its own and must be inventoried here. + // Bare local values are deliberately excluded: names such as `timeout` + // and `random` do not identify an ambient API without a qualified path. + if path.contains("::") { + self.record_api(path.clone()); + } if path.ends_with("::OsRng") || path == "OsRng" { self.record_type(path); } @@ -474,7 +482,7 @@ fn classify_api(api: &str) -> Vec<&'static str> { { categories.push("clock-timer"); } - if matches!( + if (matches!( last, "random" | "rng" @@ -486,7 +494,8 @@ fn classify_api(api: &str) -> Vec<&'static str> { ) && (api.contains("rand") || api.contains("getrandom") || api.contains("SecretKey") - || api.starts_with("")) + || api.starts_with(""))) + || (last == "fill" && api.contains("getrandom")) { categories.push("entropy-random"); }