diff --git a/ROADMAP.md b/ROADMAP.md index 6a593e27f..28c6aba36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,29 +149,28 @@ APIs, provider routes, network locations, or credentials. The dependency order for this work is strict: -A. review the canonical v1 source-only contract and operational contract - surface, including the exact EVM `has_access_by_content_id` - policy/evidence contract, recipient-key authorization, immutable custody - epochs, and the authenticated replay-pending Runtime-to-release-node - envelope; -B. build source-only custody-node operations and durable node state on top of - that contract. The published custody branch covers the node-local durable - dual-key claim, claim-gated release, exact encrypted-result persistence, and - exact retry replay. Remaining work is issuer lifecycle, node admission, - rotation, recovery, operational review, and recovery from a durable claim - that has no durable result; -C. replace the provisional DTO/provider surface atomically and wire - Runtime/Wallet/provider integration to the reviewed contract, with one - source allow flow and one source deny flow; and -D. prove the installed end-to-end open/decrypt/render path. - -The published source-only stack currently ends at -`origin/feat/protected-content-key-reconstruction` (`a8d0a9a6`). The local -custody-pool work is unpublished policy-contract source only. PR #15 / -`feat/dkms-esp-port` is research evidence for threshold crypto, node-local -custody, lifecycle scenarios, and UX shape; its public `shares[]` metadata, -raw-CEK/reference operations, old DRM orchestration, direct TCP/IP topology, -and standalone harness must not become the product path. +A. define and review the canonical source contracts and custody crypto + boundary; +B. define source-only provider protocols and custody-node state without making + them active product paths; +C. add Wallet-rights, typed Chain evidence, and Runtime-owned durable + coordination as source-only prerequisites; +D. implement inactive Runtime provider lifecycle, registration, routing, and + durable reconciliation before or with a typed rights-provider adapter; +E. implement producer minting, three-node custody provisioning, recipient + possession, private decrypt output, and a full inactive end-to-end proof; and +F. only then atomically replace the provisional DTO/provider surface and prove + the installed mint -> buy -> open -> play path. + +The published protected-content review line now reaches +`origin/feat/protected-content-custody-provider` (`f7cd6c3d`), which is still +source-only and unregistered. Local descendants add Wallet-rights, a private +Runtime coordinator, typed Chain evidence, and a typed rights evaluator; those +remain unpublished source work. PR #15 / `feat/dkms-esp-port` is research +evidence for threshold crypto, node-local custody, lifecycle scenarios, and UX +shape; its public `shares[]` metadata, raw-CEK/reference operations, old DRM +orchestration, direct TCP/IP topology, and standalone harness must not become +the product path. Carrier remains transport only throughout that sequence. It carries Runtime-selected traffic, but it does not define rights authority, custody @@ -182,17 +181,19 @@ algorithm metadata, provenance, availability receipts, declared viewer interface, CEK commitment, and object-bound pool/epoch/committee-authorization identities. Public metadata must not carry custody shares. `CustodyEnvelopeV1` remains a private ephemeral provisioning bundle; durable custody storage is one -node-sealed share per selected custody node. Cryptographic upgrades use -versioned envelopes and migration rules. A permissioned key service can precede -a public network, but -production claims require independent review and operational evidence. - -The repository now has a canonical source-only v1 review candidate in -[Protected-content v1 contracts](docs/PROTECTED_CONTENT_CONTRACTS_V1.md). The -current installed/provider path still uses the older provisional -`elastos_common::protected_content` DTOs plus fail-closed provider stubs, so -see [Protected content](docs/PROTECTED_CONTENT.md) and the provider-specific -contracts as the current integration surface, not as proof that v1 is wired. +node-sealed share per selected custody node. The first product proof must use +three distinct custody provider identities and state roots for a 2-of-3 +committee. Cryptographic upgrades use versioned envelopes and migration rules. +A permissioned key service can precede a public network, but production claims +require independent review and operational evidence. + +The repository now has a canonical source-only v1 review line in +[Protected-content v1 contracts](docs/PROTECTED_CONTENT_CONTRACTS_V1.md) and +[Protected content](docs/PROTECTED_CONTENT.md). The current installed/provider +path still uses the older provisional `elastos_common::protected_content` DTOs +plus fail-closed provider stubs. The canonical source line is not product proof +until Runtime, providers, Library, Wallet, Chain, custody, decrypt output, and +viewer are connected in one no-fallback path. ### 4. Add wallet, DID, and node proofs behind Runtime authority diff --git a/TASKS.md b/TASKS.md index d59c28a38..2892e9d24 100644 --- a/TASKS.md +++ b/TASKS.md @@ -709,208 +709,125 @@ and review items pass. ### Trusted content and access rights -The published source-only protected-content stack currently ends at -`origin/feat/protected-content-key-reconstruction` at `a8d0a9a6`: canonical -contracts, custody and node operations, then decrypt-boundary reconstruction. -The local `feat/protected-content-custody-pool` branch is source-only and -unpublished. Remaining work must stay in this order. Carrier remains transport -only throughout every stage below. - -The published custody branch defines the source-only EVM -`has_access_by_content_id` policy/evidence contract, including the exact -contract right string mapped from one product action, Profile-signed -recipient-key authorization, signed immutable custody epoch, and authenticated -replay-pending Runtime release-operation envelope. It also adds the node-local -durable dual-key replay store and the private claim-gated transition from -authenticated replay-pending evidence to an exact persisted encrypted -contribution. Exact retries replay only that result after restart. Remaining -tasks start after those boundaries; they do not reopen them as ambient provider -configuration. Runtime owns its own orchestration replay. Each custody node -owns only its local claim and result. A crash after the claim becomes durable -but before the result becomes durable remains fail closed and requires a fresh -Runtime release operation; there is no operation-resume journal for that state. - -#### A. Source-only review gate - -- [ ] Obtain external cryptographic, custody, and contract review of - `docs/PROTECTED_CONTENT_CONTRACTS_V1.md`, the source-only - `elastos-protected-content-contracts` crate, and the source-only - `elastos-protected-content-custody` crate before any provider or product - integration. The branch-local source review already closed with no code - findings after the shared strict DID codec and Carrier codec consolidation; - that was not an external cryptographic audit or production security - approval. An independent AI/model review later found and prompted the local - custody remediation for invalid X25519 contract bytes, exact released - threshold settlement, and post-reconstruction CEK commitment checking; that - review is also not a professional external audit. Review the canonical - codec, domain tags, field bounds, EIP-191 recovery (`0/1` only), canonical - Profile `did:key`, policy identity and body, recipient-key authorization, - owner-Wallet recovery, Runtime operation issuer binding, atomic replay keys, - signed custody epochs, node decisions, terminal issuer signatures, nested - authority windows including contribution-to-terminal settlement, shared - canonical Ed25519 validation (including noncanonical-encoding rejection), - the stricter local canonical X25519 contract-key rule, custody-envelope AAD - domains, pinned HPKE share sealing, GF256 Shamir share handling, - exact-threshold release settlement, threshold reconstruction zeroization, - manifest-bound reconstructed-key commitment checking, unique contribution - commitments at threshold settlement, and golden vectors. -- [ ] Add professionally reviewed wrapper-level known-answer coverage for the - pinned HPKE share framing if a grounded upstream vector path becomes - available. The current source keeps tamper and binding tests, but the pinned - upstream `hpke` 0.13 vector set does not cover our exact fixed 32-byte share - wrapper framing. - -#### B. Source-only custody node operations and durable node state - -- [ ] Complete the remaining custody operational design around the reviewed - epoch and release-operation contracts: node admission and rotation, review or - replacement of the pinned threshold and recipient-encryption suites, - revocation, audit retention, issuer-key lifecycle, and recovery operations. - The published source-only stack through - `origin/feat/protected-content-key-reconstruction` now implements new - content share provisioning, recipient-sealed node release, and - decrypt-boundary threshold reconstruction, but it does not yet prove full - operational custody safety, complete node-state durability, or product - integration. Its manifest-bound CEK commitment detects a wrong - reconstructed key; it does not identify the malicious node or add verifiable - secret sharing. -- [ ] Keep the new custody pool, each object's selected committee, and any - later Runtime route resolution as three separate truths. -- [ ] Bind each protected object or manifest to the exact custody-pool - identity, exact immutable `SignedCustodyEpochV1` identity, and exact - `CustodyCommitteeAuthorizationIdentityV1` so Runtime open can require those - object-bound identities rather than any newer pool or authorization. -- [ ] Define the explicit re-share and new-epoch transition that replaces an - object's committee only by minting a new signed custody epoch plus a new - bound committee authorization, without rewriting older object authority. -- [ ] Keep `CustodyEnvelopeV1` private to the trusted mint/custody provisioning - handoff. It must not become public asset metadata or capsule-visible state. - Future durable custody storage persists exactly one node-sealed share at each - selected custody node. Public metadata contains no shares: only bounded - identities, threshold/epoch/pool facts, CEK commitment, and signatures. -- [ ] Extend the reviewed node-local replay-claim and claim-gated release - transition into full operational node state: retained claim pruning policy, - multi-process operational audit retention, node admission and issuer-key - lifecycle, recovery tooling, and storage review for production hosts. The - published custody branch already proves one owner-only durable dual-key - claim store, atomic no-partial-claim updates, restart survival, private - claim-before-release authority, exact encrypted-result persistence, and exact - retry replay. It does not yet add an operation-resume journal for a claim - that became durable before its result, so that state still requires a fresh - Runtime release operation. -#### C. Atomic Runtime/provider cutover plus source allow/deny proof - -- [ ] Add a typed protected-content Wallet operation that verifies an - externally completed signature, canonicalizes a `27/28` recovery byte to - `0/1` after signer verification and before `WalletSignedRightsRequestV1` - construction, and covers managed, MetaMask, and WalletConnect paths with - exact tests. The v1 contract itself continues to accept only canonical - `0/1`. -- [ ] Implement how Runtime derives the recipient public key from the - authenticated Profile and session, proves holder-only key possession, and - selects an approved encryption suite. The current Profile signature - authorizes one exact recipient public key and one Runtime operation issuer; - it does not prove X25519 secret-key possession. The caller must not provide - endpoint, device, route, or Carrier identity as recipient authority. -- [ ] Implement the Runtime-owned replay claim store and orchestration around - the reviewed contract values. Runtime must generate a fresh opaque 32-byte - session binding, fresh rights and release nonces, and bounded time windows; - capsules must not select replay or session authority. -- [ ] Pin approved chain ids, contract addresses, selectors, and ABI fixtures - inside provider policy and evaluation evidence for `RightsPolicyBodyV1`. - The source-only contract already requires those typed inputs; this stage - chooses the approved production values and wires them into provider policy. - Keep chain URLs and transport selection outside capsule/provider contracts. -- [ ] Wire Runtime-owned protected-content orchestration only through the - reviewed v1 contracts: content status/fetch, Wallet-approved rights request, - recipient-key authorization, application-authenticated release operation, - independent node rights checks, key release, scoped decrypt/render session, - terminal receipt, audit, cancellation, and cleanup. Runtime must select - providers and Carrier endpoints; capsules must not select routes or receive - contributions or raw keys. Bind the fresh opaque contract value to the - verified session in Runtime state; never place a session ID, session cookie, - launch token, capability token, or bearer credential in the contract. -- [ ] Keep the later Runtime open/buy integration aligned with the reviewed - authority model: viewer launches must use Home projection authority; Runtime - must derive Wallet v2 authority from verified launch authority; Chain may - prepare nonce/gas, but approval and broadcast must use the existing durable - Runtime transaction coordinator. Do not add Creator-local transaction - workflow, broad chain-scan fallback, debug logging flood, CBC envelope, or - sidecar/WireGuard/topology path. The public, unmerged PR #15 commits `ffea5998` - and `e148218b` are source evidence only, not current code or accepted - behavior. -- [ ] Replace the provisional `elastos_common::protected_content` DTOs and - their current provider consumers atomically with the reviewed v1 crate during - the integration slice. Delete the superseded shapes; do not keep parallel - authorities, compatibility adapters, or fallback decoding. -- [ ] Add reviewed production dDRM rights-method configuration for the approved - ESC/Elacity contracts only after contract addresses, selectors, and ABI - fixtures are pinned in tests. -- [ ] Wire `rights-provider` to approved dDRM/chain policy backends through - typed `chain-provider` reads, with every release node verifying the Wallet's - exact right independently. Do not put license logic in app UI or - gateway-only checks. -- [ ] Wire Runtime-selected custody providers to the reviewed threshold-custody - backend only after - node contribution authentication, same-node rights evidence, - recipient-sealing encryption, exact-threshold settlement, durable replay - rejection, expiry, revocation, and failure cleanup are proven. Verify the - capsule-visible response is the authenticated terminal receipt, not - provider-private contribution bytes or key-backend authority. -- [ ] Atomically cut Runtime/Library over from the provisional `key-provider` - route to Runtime-selected custody providers keyed by `node_public_key`, - keeping pool eligibility policy, per-object committee authority, and current - reachability separate. Runtime must own provider lifecycle/registration, - choose a currently available typed provider route for each exact committee - node, produce one real allow/deny/replay audit over provider selection, and - leave no fallback rail or dual authority. Carrier remains transport only; do - not add service IDs, endpoint identities, or topology to the signed pool or - custody epoch. -- [ ] Wire `decrypt-provider` to a real decrypt/render backend that returns - scoped rendered output or decrypt sessions to the viewer instead of broad raw - key access. -- [ ] Define and wire the private decrypt-provider output/read boundary over - the canonical authenticated chunk payload. The local unpublished - `feat/protected-content-decrypt-output` child branch currently proves only - custody-local staged decrypt output from the exact authenticated release - inputs, full ciphertext identity verification before plaintext write, and - chunk authentication inside the custody boundary. It does not yet add - provider wire, Runtime orchestration, viewer streaming, or product - integration. -- [ ] Evaluate decrypt/render/media helper crates as provider-internal - implementation candidates only after the fail-closed protected-content - sequence is wired and source-path tests prove capsules receive neither key - material nor IPFS, chain, Wallet, or route authority. Source contract - field-name tests are not confidentiality proof. -- [ ] Prove one source allow flow and one source deny flow through the reviewed - Runtime/provider contract path with no fallback or parallel DTO surface. - -#### D. Isolated installed proof - -- [ ] Wire real protected-content producers to the existing `sealed` content - object contract after payload encryption, rights policy, availability - receipt, provenance, key-envelope, and viewer-interface generation exist. -- [ ] Prove one honest protected-content flow end to end on an installed - target: resolve object by stable identity, verify trust material, authorize - access, decrypt for the rightful user, open in the correct viewer/app, and - fail closed for everyone else. -- [ ] Keep PR #15 / `feat/dkms-esp-port` limited to source evidence for later - UX and node-operation extraction only. Keep as research its threshold crypto, - node-local custody, recipient-sealed contributions, CEK commitment, lifecycle - scenarios, and fail-closed negative tests. Reimplement at the canonical - boundary: per-node durable shard storage, DKG/rotation/re-share/revocation, - pool/governance policy, provider roles, and Runtime-open scenarios. Reject - from the product path: public aggregated `shares[]` metadata, capsule-owned - authority, raw CEK operations, `rail_shim`/reference fallbacks, old - `drm-provider` orchestration, direct TCP/IP/port topology in capsules or - contracts, static authorization fallbacks, and the standalone harness as a - product route. Treat PR #15's generated `escrow.json` as historical dev - evidence only: it aggregates wrapped shares and its writer omits - `cek_commitment_b64` while the reload path requires it. -- [ ] Only after stages A-D, decide whether to add permissioned ElastOS - PQ-hybrid dKMS custody for new protected content. Do not use FROST as the - long-term dKMS root; FROST is only a classical helper for receipts or cohort - decisions. +Protected-content work stays source-only until the complete mint -> buy -> open +-> play path is proven through Runtime. Carrier remains transport only, Home +launch tokens remain HTTP-edge credentials, and capsules never receive CEKs, +raw shares, provider routes, host/IP/port data, credentials, or transferable +playback URLs. + +Source prerequisites already present in this branch ancestry: + +- Published review stack through + `origin/feat/protected-content-key-reconstruction` at `a8d0a9a6`: canonical + protected-content contracts, custody-envelope provisioning, recipient-sealed + node release, exact-threshold reconstruction inside the decrypt boundary, + commitment checking, replay-pending release authority, and fail-closed + tamper/expiry/wrong-binding tests. +- Published source-only custody-provider review line at + `origin/feat/protected-content-custody-provider` / `f7cd6c3d`: object-bound + pool/epoch/committee-authorization identity, authenticated chunk payload and + staged decrypt-output helpers, one selected node/one sealed share per record, + expected Runtime issuer pinning, owner-only durable node-share storage, exact + duplicate/conflict/restart behavior, signed-rights-gated release, bounded + provider frames, strict shutdown, and no CEK/share/topology exposure. It is + unregistered and not installed product behavior. +- Local source-only Wallet-rights branch at `2c69d0c2`: a dedicated Wallet + operation signs the exact canonical `RightsRequestV1` bytes for the selected + active EVM account through the existing verified Wallet invocation context; + generic approval cannot substitute for it. +- Local source-only Runtime coordinator branch at `b00bfeeb`: private + durable Runtime release state plus typed internal coordination over existing + Wallet, rights, and custody provider contracts. It persists before provider + effects, records effect-started state, replays only exact terminal results, + leaves ambiguous post-dispatch outcomes nonterminal, and does not expose or + accept provider topology. +- Local source-only chain-rights evidence branch at `7c747253` and rights + evaluator branch at `a32ae85a` (the latest code commit before this docs-only + planning commit): typed chain-rights evidence plus a typed rights evaluator + using EIP-1898-style exact block hash/number binding, live chain-id + verification, selector/method binding, bounded freshness, Runtime-operation + hash binding, redacted upstream failures, and no caller-supplied rights + evidence. Current planning truth lives on the `feat/protected-content-rights` + branch, not on a self-referential code hash. + +Remaining work, in strict dependency order. Review/publish hygiene still applies +before any local source-only child branch is used as a parent, but it is not a +product implementation stage: + +- [ ] Implement the inactive Runtime provider lifecycle, registration, routing, + and durable reconciliation seam before or in the same slice as any typed + rights-provider process adapter. Runtime must own provider selection, + readiness, registration, durable journal, audit, retry, and terminal + settlement. It must reconcile identity-bound durable claims or operations that + survive a crash after provider effects but before a terminal result, require a + terminal receipt before retirement, and never use time, path absence, provider + absence, fallback, or dual authority as settlement truth. Carrier may + transport selected endpoint calls; capsules must not choose providers, nodes, + routes, endpoints, hosts, IP addresses, ports, or credentials. +- [ ] Add the typed provider-process adapter for the rights evaluator through + that Runtime-owned/provider-registry seam only. It must not use direct RPC, + static evidence, caller-supplied evidence, fallback, or topology in + requests/results. +- [ ] Implement recipient possession and decrypt-session authority: Runtime + derives a recipient public key from the authenticated Profile/session and + proves holder-only possession before the decrypt boundary reconstructs or + uses the CEK. The existing Profile signature authorizes one public key; it is + not possession proof. +- [ ] Implement the Runtime-owned producer/mint journal/state machine for one + media flow only: stage ciphertext and object bytes; bind exact encrypted + object, key envelope, custody pool, custody epoch, committee authorization, + node set, threshold, CEK commitment, rights policy, viewer interface, and + availability requirements; record exact per-stage receipts; provision one + sealed share to each selected custody node; recover cleanly after restart; + commit availability; then list. On partial custody provisioning, Runtime must + record a durable terminal mint abort, never list or expose the object, and + retain any accepted orphan shares as unreachable by every valid release + operation. Later garbage collection of those orphans requires a separately + reviewed signed custody retirement operation with terminal receipts; that + operation does not exist now. Before installed acceptance, choose one explicit + policy: implement that exact retirement operation, or prove bounded orphan + retention is the accepted first-release policy. Listing must never commit + before ciphertext/object availability and required custody provisioning are + durable. The first proof must use three distinct custody-provider instances + for 2-of-3: three distinct node identities, three distinct owner-only state + roots, distinct operators/failure domains, exact duplicate/conflict/restart + proof, and no one-node shortcut. +- [ ] Connect buy/open to real Wallet and Chain evidence. Wallet signs the exact + approved action; Chain supplies final typed rights evidence; approval and + broadcast use the existing durable Runtime transaction coordinator. Runtime + then coordinates private reconstruction/decryption and scoped viewer output. + Playback output must be short-lived and bound to exact principal, launch or + Runtime session, viewer, object identity, action, and expiry. Runtime resolves + object identity; docs and APIs must not specify a bearer `play_url`. Home + launch tokens must not become Wallet or protected-content authority. +- [ ] Prove the full inactive Runtime product path before removing the + provisional path: mint -> buy -> open -> play for two principals; three + custody nodes; allow, deny, tamper, wrong object, + wrong/missing/duplicate/malicious contribution, replay, restart/crash, + cleanup, durable reconciliation, terminal receipts, and no + CEK/share/topology leakage. +- [ ] Atomically replace the provisional `elastos_common::protected_content`, + `drm-provider`, `key-provider`, and provisional decrypt authority only after + the complete inactive replacement path is proven. Delete the superseded + authority surface in that cutover slice; do not keep compatibility decoders, + fallbacks, dual writes, dual authority, or a second journal. +- [ ] Update the minimum UI only after the authority path is green, then prove + installed two-principal acceptance: Create for one media asset, Store + listing/detail/buy, Library open, Wallet/Home approval, and one + Runtime-selected viewer. Documents, Store consolidation, broad UI redesign, + extra formats, migration, compatibility, Iroh upgrades, deployment, and + release remain later work unless the first flow proves they are required. + +PR #15 / `feat/dkms-esp-port` remains source evidence only: keep its mint/open +playback behavior, threshold crypto shape, node-local custody direction, +recipient-sealed contributions, CEK commitment, lifecycle scenarios, and +fail-closed negative tests. Reimplement durable shard storage, DKG/rotation/ +re-share/revocation, pool/governance policy, provider roles, Runtime-open +scenarios, and UX at the canonical boundaries above. Reject public aggregate +`shares[]` metadata, capsule-owned authority, raw CEK operations, +`rail_shim`/reference fallbacks, old DRM orchestration, direct TCP/IP/port +topology in capsules or contracts, static authorization fallbacks, and the +standalone harness as a product route. ### Operator and audit hardening - [ ] Keep the existing SHA-256 audit chain canonical for 0.6 unless an explicit diff --git a/capsules/chain-provider/Cargo.lock b/capsules/chain-provider/Cargo.lock index edd538c92..9a6d17c41 100644 --- a/capsules/chain-provider/Cargo.lock +++ b/capsules/chain-provider/Cargo.lock @@ -2,18 +2,101 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.1" @@ -29,6 +112,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -48,6 +140,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -67,13 +161,79 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" name = "chain-provider" version = "0.1.0" dependencies = [ + "ed25519-dalek", + "elastos-auth", "elastos-guest", + "elastos-protected-content-contracts", + "k256", "reqwest", "serde", "serde_json", "sha2", + "sha3", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -83,6 +243,33 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -90,9 +277,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -100,7 +349,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -114,6 +365,63 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elastos-auth" +version = "0.6.0" +dependencies = [ + "chrono", + "hex", + "k256", + "serde", + "sha2", + "sha3", +] + [[package]] name = "elastos-guest" version = "0.1.0" @@ -123,6 +431,87 @@ dependencies = [ "serde_json", ] +[[package]] +name = "elastos-identity" +version = "0.6.0" +dependencies = [ + "aes-gcm", + "anyhow", + "aws-lc-rs", + "base64", + "bs58", + "ciborium", + "curve25519-dalek 4.1.3", + "ed25519-dalek", + "hex", + "hkdf", + "p256", + "rand 0.8.7", + "serde", + "serde_json", + "sha2", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "elastos-protected-content-contracts" +version = "0.6.0" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519-dalek", + "elastos-auth", + "elastos-identity", + "k256", + "serde", + "sha2", + "sha3", + "thiserror 1.0.69", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -138,6 +527,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -195,6 +590,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -219,11 +615,78 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -425,6 +888,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -447,6 +919,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.97" @@ -459,6 +941,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "libc" version = "0.2.186" @@ -500,12 +1005,48 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -518,6 +1059,34 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -536,6 +1105,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -559,7 +1137,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -574,13 +1152,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -615,14 +1193,41 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -632,7 +1237,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -684,6 +1298,16 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -694,7 +1318,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -704,6 +1328,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.40" @@ -736,7 +1369,7 @@ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -751,6 +1384,26 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -813,16 +1466,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.12" @@ -845,6 +1518,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -888,13 +1571,33 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1009,9 +1712,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1039,6 +1754,22 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -1063,6 +1794,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1420,6 +2162,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/capsules/chain-provider/Cargo.toml b/capsules/chain-provider/Cargo.toml index 2d68c81eb..5457c9371 100644 --- a/capsules/chain-provider/Cargo.toml +++ b/capsules/chain-provider/Cargo.toml @@ -11,12 +11,17 @@ path = "src/main.rs" [dependencies] elastos-guest = { path = "../../elastos/crates/elastos-guest" } +elastos-protected-content-contracts = { path = "../../elastos/crates/elastos-protected-content-contracts" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } sha2 = "0.10" [dev-dependencies] +elastos-auth = { path = "../../elastos/crates/elastos-auth" } +ed25519-dalek = "2.1" +k256 = { version = "0.13", features = ["ecdsa"] } +sha3 = "0.10" [profile.release] opt-level = "s" diff --git a/capsules/chain-provider/capsule.json b/capsules/chain-provider/capsule.json index aa0cdb7db..305aff1c7 100644 --- a/capsules/chain-provider/capsule.json +++ b/capsules/chain-provider/capsule.json @@ -14,7 +14,7 @@ { "resource": "elastos://chain/*", "actions": ["read", "write", "admin"], - "operations": ["networks", "status", "block_number", "sync_health", "balance", "transaction", "receipt", "has_access_by_content_id", "proof", "erc1271_is_valid_signature", "prepare_transaction", "broadcast_transaction", "node_lifecycle"] + "operations": ["networks", "status", "block_number", "sync_health", "balance", "transaction", "receipt", "proof", "erc1271_is_valid_signature", "prepare_transaction", "broadcast_transaction", "node_lifecycle"] } ], "audit_events": ["chain.read", "chain.status", "chain.rights_read", "chain.proof", "chain.broadcast", "chain.node_lifecycle"] @@ -88,15 +88,6 @@ "resource": "elastos://chain/*", "operation": "receipt" }, - { - "id": "has_access_by_content_id", - "description": "Request has access by content id through the chain-provider Runtime provider boundary for elastos://chain/*.", - "risk": "rights", - "approval": "runtime_policy", - "audit": "event", - "resource": "elastos://chain/*", - "operation": "has_access_by_content_id" - }, { "id": "proof", "description": "Request proof through the chain-provider Runtime provider boundary for elastos://chain/*.", diff --git a/capsules/chain-provider/src/backends.rs b/capsules/chain-provider/src/backends.rs index 19a92e9a9..0534e7e61 100644 --- a/capsules/chain-provider/src/backends.rs +++ b/capsules/chain-provider/src/backends.rs @@ -173,7 +173,7 @@ impl ChainProvider { "params": params, })) .send() - .map_err(|err| Response::error("upstream_unreachable", &err.to_string()))?; + .map_err(|_| Response::error("upstream_unreachable", "EVM RPC request failed"))?; let status = response.status(); if !status.is_success() { return Err(Response::error( @@ -183,9 +183,12 @@ impl ChainProvider { } let body = response .json::() - .map_err(|err| Response::error("upstream_invalid_json", &err.to_string()))?; - if let Some(error) = body.get("error") { - return Err(Response::error("upstream_rpc_error", &error.to_string())); + .map_err(|_| Response::error("upstream_invalid_json", "EVM RPC response malformed"))?; + if body.get("error").is_some() { + return Err(Response::error( + "upstream_rpc_error", + "EVM RPC request rejected", + )); } body.get("result") .cloned() diff --git a/capsules/chain-provider/src/config.rs b/capsules/chain-provider/src/config.rs index 541705451..6a55a604f 100644 --- a/capsules/chain-provider/src/config.rs +++ b/capsules/chain-provider/src/config.rs @@ -93,6 +93,9 @@ pub(super) fn validate_rights_methods(network: &ChainNetwork) -> Result<(), Stri pub(super) fn validate_rpc_url(network: &ChainNetwork) -> Result<(), String> { let url = network.rpc_url.trim(); + if url_has_userinfo(url) { + return Err(format!("invalid RPC URL for {}", network.id)); + } if network.kind == ChainKind::BitcoinCoreRpc { if url.is_empty() || url.starts_with("http://127.0.0.1:") @@ -114,3 +117,11 @@ pub(super) fn validate_rpc_url(network: &ChainNetwork) -> Result<(), String> { } Ok(()) } + +fn url_has_userinfo(url: &str) -> bool { + let Some((_, rest)) = url.split_once("://") else { + return false; + }; + let authority = rest.split('/').next().unwrap_or_default(); + authority.contains('@') +} diff --git a/capsules/chain-provider/src/main.rs b/capsules/chain-provider/src/main.rs index 803432a37..95ea31f94 100644 --- a/capsules/chain-provider/src/main.rs +++ b/capsules/chain-provider/src/main.rs @@ -4,6 +4,11 @@ //! Apps never receive raw RPC URLs or arbitrary JSON-RPC passthrough. use elastos_guest::prelude::*; +use elastos_protected_content_contracts::{ + CanonicalContract, EvmRightsMethodAbiV1, RightsEvaluationEvidenceV1, RightsPolicyBodyV1, + RightsSubjectSourceV1, RuntimeOperationIssuerKeyV1, SignedRuntimeReleaseOperationV1, + MAX_RIGHTS_EVIDENCE_LIFETIME_SECS, +}; use serde_json::{json, Value}; use std::io::{self, BufRead, Write}; use std::path::PathBuf; @@ -33,6 +38,7 @@ const PROVIDER_VERSION: &str = match option_env!("ELASTOS_RELEASE_VERSION") { }; const NODE_LIFECYCLE_CONTROL_REASON: &str = "node lifecycle control requires an operator-approved supervisor"; +const MAX_PROTECTED_CONTENT_RUNTIME_OPERATION_BYTES: usize = 16384; struct ChainProvider { networks: Vec, @@ -41,6 +47,8 @@ struct ChainProvider { node_lifecycle_state: NodeLifecycleStateFile, node_lifecycle_state_error: Option, node_supervisor: NodeSupervisorConfig, + protected_content_runtime_issuer: Option, + now_unix_seconds: fn() -> u64, } impl ChainProvider { @@ -66,6 +74,8 @@ impl ChainProvider { node_lifecycle_state, node_lifecycle_state_error, node_supervisor: NodeSupervisorConfig::default(), + protected_content_runtime_issuer: None, + now_unix_seconds: now_ts, } } @@ -122,13 +132,9 @@ impl ChainProvider { Request::Logs { network, filter } => self.logs(&network, filter), Request::Transaction { network, hash } => self.transaction(&network, &hash), Request::Receipt { network, hash } => self.receipt(&network, &hash), - Request::HasAccessByContentId { - network, - contract, - content_id, - subject, - right, - } => self.has_access_by_content_id(&network, &contract, &content_id, &subject, &right), + Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation, + } => self.protected_content_rights_evidence(&signed_runtime_release_operation), Request::Proof { network, proof_kind, @@ -158,7 +164,7 @@ impl ChainProvider { fn init(&mut self, config: Value) -> Response { let extra = config.get("extra").unwrap_or(&config); - if let Some(networks) = config + let next_networks = if let Some(networks) = config .get("extra") .and_then(|extra| extra.get("networks")) .or_else(|| config.get("networks")) @@ -168,22 +174,44 @@ impl ChainProvider { if let Err(err) = validate_networks(&networks) { return Response::error("invalid_config", &err); } - self.networks = networks; + Some(networks) } Err(err) => return Response::error("invalid_config", &err.to_string()), } - } - if let Some(supervisor) = extra.get("node_supervisor") { + } else { + None + }; + let next_supervisor = if let Some(supervisor) = extra.get("node_supervisor") { match serde_json::from_value::(supervisor.clone()) { Ok(supervisor) => { if let Err(err) = validate_node_supervisor_config(&supervisor) { return Response::error("invalid_config", &err); } - self.node_supervisor = supervisor; + Some(supervisor) } Err(err) => return Response::error("invalid_config", &err.to_string()), } + } else { + None + }; + let next_runtime_issuer = if let Some(runtime_issuer) = config + .get("extra") + .and_then(|extra| extra.get("protected_content_runtime_issuer")) + { + match parse_runtime_issuer(runtime_issuer) { + Ok(runtime_issuer) => Some(runtime_issuer), + Err(err) => return Response::error("invalid_config", &err), + } + } else { + None + }; + if let Some(networks) = next_networks { + self.networks = networks; } + if let Some(supervisor) = next_supervisor { + self.node_supervisor = supervisor; + } + self.protected_content_runtime_issuer = next_runtime_issuer; Response::ok(json!({ "provider": "chain", "protocol_version": "1.0", @@ -705,65 +733,240 @@ impl ChainProvider { } } - fn has_access_by_content_id( + fn protected_content_rights_evidence( &self, - network_id: &str, - contract: &str, - content_id: &str, - subject: &str, - right: &str, + signed_runtime_release_operation: &str, ) -> Response { - let network = match self.evm_network(network_id) { - Ok(network) => network, - Err(response) => return response, + let operation = match decode_contract_hex::( + signed_runtime_release_operation, + MAX_PROTECTED_CONTENT_RUNTIME_OPERATION_BYTES, + "signed_runtime_release_operation", + ) { + Ok(operation) => operation, + Err(err) => return Response::error("invalid_runtime_operation", &err), }; - if let Err(err) = validate_evm_address(contract) { - return Response::error("invalid_contract", &err); - } - if let Err(err) = validate_content_id(content_id) { - return Response::error("invalid_content_id", &err); + let expected_runtime_issuer = match self.protected_content_runtime_issuer { + Some(issuer) => issuer, + None => { + return Response::error( + "runtime_issuer_not_configured", + "protected-content Runtime issuer is not configured", + ) + } + }; + let now = (self.now_unix_seconds)(); + let authenticated = match operation.verify(expected_runtime_issuer, now) { + Ok(authenticated) => authenticated, + Err(_) => { + return Response::error( + "invalid_runtime_operation", + "signed Runtime operation is invalid", + ) + } + }; + let policy = operation.statement().policy_body(); + let request = operation.statement().evidence_request(); + if let Err(err) = request.validate_against_policy(policy) { + return Response::error("invalid_runtime_operation", &err.to_string()); } - if let Err(err) = validate_evm_address(subject) { - return Response::error("invalid_subject", &err); + if policy.subject_source() != RightsSubjectSourceV1::WalletAddress { + return Response::error( + "unsupported_rights_subject_source", + "rights subject source is not supported", + ); } - if let Err(err) = validate_right(right) { - return Response::error("invalid_right", &err); + let network = match self.evm_network_for_protected_content_policy(policy) { + Ok(network) => network, + Err(response) => return response, + }; + let chain_id = policy.chain_id(); + let method = match policy.method_abi() { + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString => { + let contract = format!("0x{}", encode_hex(policy.contract_address().as_bytes())); + match rights_method(network, "has_access_by_content_id", &contract) { + Ok(method) + if method.selector + == format!( + "0x{}", + encode_hex(policy.function_selector().as_bytes()) + ) => + { + method + } + Ok(_) => { + return Response::error( + "rights_selector_mismatch", + "configured rights method selector does not match policy", + ) + } + Err(response) => return response, + } + } + }; + match self.evm_rpc(network, "eth_chainId", json!([])) { + Ok(value) => match value.as_str().and_then(|value| parse_hex_u64(value).ok()) { + Some(live_chain_id) if live_chain_id == chain_id => {} + Some(_) => { + return Response::error( + "chain_id_mismatch", + "live chain id does not match policy chain id", + ) + } + None => { + return Response::error( + "upstream_invalid_chain_id", + "live chain id must be a hex quantity", + ) + } + }, + Err(response) => return response, } - let method = match rights_method(network, "has_access_by_content_id", contract) { - Ok(method) => method, + + let head = match self.evm_rpc(network, "eth_blockNumber", json!([])) { + Ok(value) => match value.as_str().and_then(|value| parse_hex_u64(value).ok()) { + Some(head) => head, + None => { + return Response::error( + "upstream_invalid_head", + "chain head must be a hex quantity", + ) + } + }, + Err(response) => return response, + }; + let min_confirmations = u64::from(policy.observation_finality().min_confirmations()); + let observed = match head.checked_sub(min_confirmations) { + Some(observed) => observed, + None => { + return Response::error( + "insufficient_finality", + "chain head is below required confirmation depth", + ) + } + }; + let observed_tag = format!("0x{observed:x}"); + let observed_hash = match self.evm_rpc( + network, + "eth_getBlockByNumber", + json!([observed_tag.as_str(), false]), + ) { + Ok(value) => match evm_observed_block(&value, observed) { + Ok(hash) => hash, + Err(err) => return Response::error("upstream_invalid_block", &err), + }, Err(response) => return response, }; + + let subject = format!("0x{}", encode_hex(request.binding().wallet().as_bytes())); let data = match method.abi { RightsMethodAbi::HasAccessByContentIdStringAddressString => { match encode_has_access_by_content_id_call( &method.selector, - content_id, - subject, - right, + policy.content_id(), + &subject, + policy.evm_right_argument(), ) { Ok(data) => data, Err(err) => return Response::error("invalid_rights_method", &err), } } }; - match self.evm_rpc( + let has_access = match self.evm_rpc( network, "eth_call", - json!([{ "to": method.contract.as_str(), "data": data }, "latest"]), + json!([ + { "to": method.contract.as_str(), "data": data }, + { + "blockHash": format!("0x{}", encode_hex(observed_hash.as_bytes())), + "requireCanonical": true + } + ]), ) { Ok(result) => match decode_evm_bool(&result) { - Ok(has_access) => Response::ok(json!({ - "network": network.id, - "contract": method.contract.as_str(), - "content_id": content_id, - "subject": subject, - "right": right, - "has_access": has_access, - })), - Err(err) => Response::error("upstream_invalid_bool", &err), + Ok(has_access) => has_access, + Err(err) => return Response::error("upstream_invalid_bool", &err), }, - Err(response) => response, + Err(response) => return response, + }; + let acquired_at = now; + let expires_at = match acquired_at + .checked_add(MAX_RIGHTS_EVIDENCE_LIFETIME_SECS) + .map(|expires_at| expires_at.min(operation.statement().expires_at())) + { + Some(expires_at) if expires_at > acquired_at => expires_at, + _ => { + return Response::error( + "invalid_evidence_window", + "rights evidence window is outside Runtime operation", + ) + } + }; + let evidence = match RightsEvaluationEvidenceV1::new( + authenticated.operation_hash(), + authenticated.release_request_hash(), + request.binding().clone(), + request.policy_identity().clone(), + request.binding().wallet(), + chain_id, + observed, + observed_hash, + head, + has_access, + acquired_at, + expires_at, + ) + .and_then(|evidence| { + evidence.validate_against_request(request, policy)?; + Ok(evidence) + }) { + Ok(evidence) => evidence, + Err(err) => return Response::error("invalid_evidence", &err.to_string()), + }; + let evidence_bytes = match evidence.canonical_bytes() { + Ok(bytes) => bytes, + Err(err) => return Response::error("invalid_evidence", &err.to_string()), + }; + let evidence_hash = match evidence.canonical_hash() { + Ok(hash) => hash, + Err(err) => return Response::error("invalid_evidence", &err.to_string()), + }; + Response::ok(json!({ + "schema": "elastos.chain.protected-content-rights-evidence/v1", + "chain_id": chain_id, + "observed_block_number": observed, + "head_block_number": head, + "observed_block_hash": format!("0x{}", encode_hex(observed_hash.as_bytes())), + "rights_evaluation_evidence": format!("0x{}", encode_hex(&evidence_bytes)), + "rights_evaluation_evidence_hash": format!("0x{}", encode_hex(evidence_hash.as_bytes())), + })) + } + + fn evm_network_for_protected_content_policy( + &self, + policy: &RightsPolicyBodyV1, + ) -> Result<&ChainNetwork, Response> { + let contract = format!("0x{}", encode_hex(policy.contract_address().as_bytes())); + let mut matches = self.networks.iter().filter(|network| { + network.kind == ChainKind::EvmJsonRpc + && network.chain_id == Some(policy.chain_id()) + && network.rights_methods.iter().any(|method| { + method.id == "has_access_by_content_id" + && method.contract.eq_ignore_ascii_case(&contract) + }) + }); + let Some(network) = matches.next() else { + return Err(Response::error( + "rights_query_not_configured", + "no configured protected-content rights evidence source matches policy", + )); + }; + if matches.next().is_some() { + return Err(Response::error( + "ambiguous_rights_evidence_source", + "multiple protected-content rights evidence sources match policy", + )); } + Ok(network) } fn proof(&self, network_id: &str, proof_kind: ChainProofKind, subject: &str) -> Response { diff --git a/capsules/chain-provider/src/protocol.rs b/capsules/chain-provider/src/protocol.rs index 2d3a55800..3f6db78d0 100644 --- a/capsules/chain-provider/src/protocol.rs +++ b/capsules/chain-provider/src/protocol.rs @@ -144,12 +144,8 @@ pub(super) enum Request { network: String, hash: String, }, - HasAccessByContentId { - network: String, - contract: String, - content_id: String, - subject: String, - right: String, + ProtectedContentRightsEvidence { + signed_runtime_release_operation: String, }, Proof { network: String, diff --git a/capsules/chain-provider/src/tests.rs b/capsules/chain-provider/src/tests.rs index fcd5a7d63..b781778c4 100644 --- a/capsules/chain-provider/src/tests.rs +++ b/capsules/chain-provider/src/tests.rs @@ -1,8 +1,28 @@ use super::*; +use ed25519_dalek::{Signer as _, SigningKey}; +use elastos_auth::ethereum_signed_message_hash; +use elastos_protected_content_contracts::{ + CanonicalContract, CustodyApprovedSuitesV1, CustodyCommitteeAuthorizationIdentityV1, + CustodyEnvelopeManifestV1, CustodyEnvelopeV1, CustodyEpochIdentityV1, CustodyEpochIssuerKeyV1, + CustodyEpochStatementV1, CustodyPoolIdentityV1, Digest32, EncryptedContentIdentityV1, + EvmContractAddressV1, EvmFunctionSelectorV1, EvmRightsMethodAbiV1, HpkeCiphertextV1, + KeyEnvelopeIdentityV1, KeyReleaseRequestV1, NodeCustodyPublicKeyV1, NodePublicKey, + ProfileIdentityV1, ProtectedContentBindingV1, RecipientKeyAuthorizationStatementV1, + RecipientKeyIdentityV1, RecipientPublicKeyBytesV1, ReplayNonce16, RightsActionV1, + RightsEvaluationEvidenceRequestV1, RightsEvaluationEvidenceV1, RightsObservationFinalityV1, + RightsPolicyBodyV1, RightsSubjectSourceV1, RuntimeOperationIssuerKeyV1, + RuntimeReleaseAuditIdV1, RuntimeReleaseOperationStatementV1, RuntimeSessionBindingV1, + ShareCoordinateV1, SignedCustodyEpochV1, SignedRecipientKeyAuthorizationV1, + SignedRuntimeReleaseOperationV1, ThresholdV1, WalletAddress, WalletSignedRightsRequestV1, + CUSTODY_HPKE_SUITE_ID_V1, HPKE_ENCAPPED_KEY_BYTES, HPKE_SEALED_SHARE_BYTES, +}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; +use k256::ecdsa::SigningKey as WalletSigningKey; +use sha3::Digest as _; + mod support; use support::*; @@ -39,6 +59,7 @@ fn provider_with_rpc(rpc_url: String) -> ChainProvider { }), }); assert!(matches!(init, Response::Ok { .. })); + provider.now_unix_seconds = || RIGHTS_EVIDENCE_NOW; provider } @@ -78,29 +99,380 @@ fn provider_with_rights_rpc(rpc_url: String, selector: &str) -> ChainProvider { let mut provider = ChainProvider::new(); let init = provider.handle(Request::Init { config: json!({ - "networks": [{ - "id": "esc-local", - "display_name": "ESC Local", - "kind": "evm_json_rpc", - "chain_id": 20, - "native_symbol": "ELA", - "provider": "test", - "mainnet": false, - "explorer_url": null, - "rpc_url": rpc_url, - "rights_methods": [{ - "id": "has_access_by_content_id", - "contract": "0x0000000000000000000000000000000000000001", - "abi": "has_access_by_content_id_string_address_string", - "selector": selector + "extra": { + "protected_content_runtime_issuer": runtime_issuer_hex(0x42), + "networks": [{ + "id": "esc-local", + "display_name": "ESC Local", + "kind": "evm_json_rpc", + "chain_id": 20, + "native_symbol": "ELA", + "provider": "test", + "mainnet": false, + "explorer_url": null, + "rpc_url": rpc_url, + "rights_methods": [{ + "id": "has_access_by_content_id", + "contract": "0x0000000000000000000000000000000000000001", + "abi": "has_access_by_content_id_string_address_string", + "selector": selector + }] }] - }] + } + }), + }); + assert!(matches!(init, Response::Ok { .. })); + provider.now_unix_seconds = || RIGHTS_EVIDENCE_NOW; + provider +} + +fn provider_with_rights_rpc_without_runtime_issuer( + rpc_url: String, + selector: &str, +) -> ChainProvider { + let mut provider = ChainProvider::new(); + let init = provider.handle(Request::Init { + config: json!({ + "extra": { + "networks": [{ + "id": "esc-local", + "display_name": "ESC Local", + "kind": "evm_json_rpc", + "chain_id": 20, + "native_symbol": "ELA", + "provider": "test", + "mainnet": false, + "explorer_url": null, + "rpc_url": rpc_url, + "rights_methods": [{ + "id": "has_access_by_content_id", + "contract": "0x0000000000000000000000000000000000000001", + "abi": "has_access_by_content_id_string_address_string", + "selector": selector + }] + }] + } }), }); assert!(matches!(init, Response::Ok { .. })); provider } +fn provider_with_rights_rpc_at( + rpc_url: String, + selector: &str, + now_unix_seconds: fn() -> u64, +) -> ChainProvider { + let mut provider = provider_with_rights_rpc(rpc_url, selector); + provider.now_unix_seconds = now_unix_seconds; + provider +} + +fn protected_content_policy_and_request( + min_confirmations: u16, +) -> (RightsPolicyBodyV1, RightsEvaluationEvidenceRequestV1) { + let mut contract = [0u8; 20]; + contract[19] = 1; + let policy = RightsPolicyBodyV1::new( + "bafybeigprotectedcontent", + RightsActionV1::View, + "view", + RightsSubjectSourceV1::WalletAddress, + 20, + EvmContractAddressV1::new(contract).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(min_confirmations), + ) + .unwrap(); + let profile_key = SigningKey::from_bytes(&[0x43; 32]); + let binding = ProtectedContentBindingV1::new( + EncryptedContentIdentityV1::new(Digest32::new([0x11; 32]), 2048).unwrap(), + KeyEnvelopeIdentityV1::new( + EncryptedContentIdentityV1::new(Digest32::new([0x11; 32]), 2048).unwrap(), + Digest32::new([0x22; 32]), + 512, + Digest32::new([0x33; 32]), + ThresholdV1::new(2, 3).unwrap(), + CustodyPoolIdentityV1::new(Digest32::new([0x44; 32]), 128).unwrap(), + CustodyEpochIdentityV1::new(Digest32::new([0x55; 32]), 128).unwrap(), + CustodyCommitteeAuthorizationIdentityV1::new(Digest32::new([0x66; 32]), 128).unwrap(), + ) + .unwrap(), + policy.policy_identity().unwrap(), + ProfileIdentityV1::from_public_key_bytes(profile_key.verifying_key().to_bytes()).unwrap(), + wallet_address(7), + RuntimeSessionBindingV1::new(Digest32::new([0x77; 32])).unwrap(), + ) + .unwrap(); + let request = + RightsEvaluationEvidenceRequestV1::new(binding, policy.policy_identity().unwrap()).unwrap(); + (policy, request) +} + +fn contract_hex(contract: &impl CanonicalContract) -> String { + format!("0x{}", encode_hex(&contract.canonical_bytes().unwrap())) +} + +fn evm_bool_word(value: bool) -> Value { + let mut bytes = [0u8; 32]; + bytes[31] = u8::from(value); + json!(format!("0x{}", encode_hex(&bytes))) +} + +const RIGHTS_EVIDENCE_NOW: u64 = 2_000_000_010; + +fn rights_evidence_now() -> u64 { + RIGHTS_EVIDENCE_NOW +} + +fn before_runtime_operation_window() -> u64 { + 1_999_999_500 +} + +fn after_runtime_operation_window() -> u64 { + 2_000_000_041 +} + +fn digest(byte: u8) -> Digest32 { + Digest32::new([byte; 32]) +} + +fn node_public_key(seed: u8) -> NodePublicKey { + NodePublicKey::new( + SigningKey::from_bytes(&[seed; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap() +} + +fn runtime_issuer(seed: u8) -> RuntimeOperationIssuerKeyV1 { + RuntimeOperationIssuerKeyV1::new( + SigningKey::from_bytes(&[seed; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap() +} + +fn runtime_issuer_hex(seed: u8) -> String { + format!("0x{}", encode_hex(runtime_issuer(seed).as_bytes())) +} + +fn wallet_address(seed: u8) -> WalletAddress { + let key = WalletSigningKey::from_slice(&[seed; 32]).unwrap(); + let encoded = key.verifying_key().to_encoded_point(false); + let digest = sha3::Keccak256::digest(&encoded.as_bytes()[1..]); + WalletAddress::new(digest[12..].try_into().unwrap()) +} + +fn recipient_public_key(seed: u8) -> RecipientPublicKeyBytesV1 { + let mut bytes = [0u8; 32]; + bytes[0] = seed.max(9); + RecipientPublicKeyBytesV1::new(bytes).unwrap() +} + +fn recipient_identity(seed: u8) -> RecipientKeyIdentityV1 { + recipient_public_key(seed) + .key_identity(CUSTODY_HPKE_SUITE_ID_V1) + .unwrap() +} + +fn signed_custody_epoch() -> SignedCustodyEpochV1 { + let issuer_key = SigningKey::from_bytes(&[0x71; 32]); + let nodes = vec![ + elastos_protected_content_contracts::CustodyNodeIdentityV1::new( + node_public_key(1), + NodeCustodyPublicKeyV1::new([0x31; 32]).unwrap(), + ShareCoordinateV1::new(1).unwrap(), + ) + .unwrap(), + elastos_protected_content_contracts::CustodyNodeIdentityV1::new( + node_public_key(2), + NodeCustodyPublicKeyV1::new([0x32; 32]).unwrap(), + ShareCoordinateV1::new(2).unwrap(), + ) + .unwrap(), + elastos_protected_content_contracts::CustodyNodeIdentityV1::new( + node_public_key(3), + NodeCustodyPublicKeyV1::new([0x33; 32]).unwrap(), + ShareCoordinateV1::new(3).unwrap(), + ) + .unwrap(), + ]; + let statement = CustodyEpochStatementV1::new( + CustodyEpochIssuerKeyV1::new(issuer_key.verifying_key().to_bytes()).unwrap(), + CustodyApprovedSuitesV1::new( + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + ) + .unwrap(), + ThresholdV1::new(2, 3).unwrap(), + nodes, + ) + .unwrap(); + SignedCustodyEpochV1::new( + statement.clone(), + issuer_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() +} + +fn custody_envelope_for_policy(policy: &RightsPolicyBodyV1) -> CustodyEnvelopeV1 { + let epoch = signed_custody_epoch(); + let manifest = CustodyEnvelopeManifestV1::new( + EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), + CustodyPoolIdentityV1::new(digest(0x25), 512).unwrap(), + epoch.epoch_identity().unwrap(), + CustodyCommitteeAuthorizationIdentityV1::new(digest(0x26), 512).unwrap(), + ThresholdV1::new(2, 3).unwrap(), + digest(0x33), + epoch.statement().nodes().to_vec(), + ) + .unwrap(); + let shares = [0x50, 0x51, 0x52] + .into_iter() + .map(|seed| { + let mut encapped_key = [0u8; HPKE_ENCAPPED_KEY_BYTES]; + encapped_key[0] = seed; + let mut ciphertext = [0u8; HPKE_SEALED_SHARE_BYTES]; + ciphertext.fill(seed); + HpkeCiphertextV1::new(encapped_key, ciphertext).unwrap() + }) + .collect(); + let envelope = CustodyEnvelopeV1::new(manifest, shares).unwrap(); + let binding = binding_for_policy_and_envelope(policy, &envelope); + assert_eq!(binding.rights_policy(), &policy.policy_identity().unwrap()); + envelope +} + +fn binding_for_policy_and_envelope( + policy: &RightsPolicyBodyV1, + envelope: &CustodyEnvelopeV1, +) -> ProtectedContentBindingV1 { + let profile_key = SigningKey::from_bytes(&[0x43; 32]); + ProtectedContentBindingV1::new( + envelope.manifest().encrypted_content().clone(), + envelope.key_envelope_identity().unwrap(), + policy.policy_identity().unwrap(), + ProfileIdentityV1::from_public_key_bytes(profile_key.verifying_key().to_bytes()).unwrap(), + wallet_address(7), + RuntimeSessionBindingV1::new(digest(0x77)).unwrap(), + ) + .unwrap() +} + +fn signed_runtime_operation_for_policy( + policy: RightsPolicyBodyV1, +) -> SignedRuntimeReleaseOperationV1 { + signed_runtime_operation_for_policy_and_runtime_seed(policy, 0x42) +} + +fn signed_runtime_operation_for_policy_and_runtime_seed( + policy: RightsPolicyBodyV1, + runtime_seed: u8, +) -> SignedRuntimeReleaseOperationV1 { + let runtime_key = SigningKey::from_bytes(&[runtime_seed; 32]); + let envelope = custody_envelope_for_policy(&policy); + let binding = binding_for_policy_and_envelope(&policy, &envelope); + let rights_request = elastos_protected_content_contracts::RightsRequestV1::new( + binding.clone(), + RightsActionV1::View, + recipient_identity(0x30), + 2_000_000_000, + 2_000_000_180, + ReplayNonce16::new([0x55; 16]), + ) + .unwrap(); + let wallet_key = WalletSigningKey::from_slice(&[7; 32]).unwrap(); + let (wallet_signature, recovery_id) = wallet_key + .sign_prehash_recoverable(ðereum_signed_message_hash( + &rights_request.canonical_bytes().unwrap(), + )) + .unwrap(); + let mut wallet_signature_bytes = wallet_signature.to_bytes().to_vec(); + wallet_signature_bytes.push(recovery_id.to_byte()); + let signed_rights = + WalletSignedRightsRequestV1::new(rights_request, wallet_signature_bytes).unwrap(); + let release_request = KeyReleaseRequestV1::new( + binding.clone(), + signed_rights.request().request_hash().unwrap(), + RightsActionV1::View, + signed_rights.request().recipient().clone(), + 2_000_000_001, + 2_000_000_050, + ReplayNonce16::new([0x66; 16]), + ) + .unwrap(); + let recipient_public_key = recipient_public_key(0x30); + let profile_key = SigningKey::from_bytes(&[0x43; 32]); + let authorization_statement = RecipientKeyAuthorizationStatementV1::new( + binding.clone(), + RightsActionV1::View, + recipient_public_key, + signed_rights.request().recipient().clone(), + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + 2_000_000_000, + 2_000_000_090, + ) + .unwrap(); + let authorization = SignedRecipientKeyAuthorizationV1::new( + authorization_statement.clone(), + profile_key + .sign(&authorization_statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(); + let evidence_request = + RightsEvaluationEvidenceRequestV1::new(binding, policy.policy_identity().unwrap()).unwrap(); + let statement = RuntimeReleaseOperationStatementV1::new( + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + signed_rights, + release_request, + recipient_public_key, + authorization, + policy, + evidence_request, + signed_custody_epoch(), + RuntimeReleaseAuditIdV1::new(digest(0x91)).unwrap(), + 2_000_000_002, + 2_000_000_040, + ) + .unwrap(); + SignedRuntimeReleaseOperationV1::new( + statement.clone(), + runtime_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() +} + +fn protected_content_signed_operation(min_confirmations: u16) -> SignedRuntimeReleaseOperationV1 { + signed_runtime_operation_for_policy(protected_content_policy_and_request(min_confirmations).0) +} + +fn wallet_subject_hex(operation: &SignedRuntimeReleaseOperationV1) -> String { + format!( + "0x{}", + encode_hex( + operation + .statement() + .evidence_request() + .binding() + .wallet() + .as_bytes() + ) + ) +} + fn provider_with_bitcoin_rpc(rpc_url: String) -> ChainProvider { let mut provider = ChainProvider::new(); init_bitcoin_rpc_provider(&mut provider, rpc_url); @@ -855,58 +1227,558 @@ fn bitcoin_sync_health_reports_initial_block_download() { } #[test] -fn has_access_by_content_id_validates_typed_inputs_before_backend() { - let mut provider = provider_with_rpc("http://127.0.0.1:9".to_string()); - let response = provider.handle(Request::HasAccessByContentId { - network: "esc-local".to_string(), - contract: "0x0000000000000000000000000000000000000001".to_string(), - content_id: "bafybeigprotectedcontent".to_string(), - subject: "0x0000000000000000000000000000000000000002".to_string(), - right: "view".to_string(), +fn protected_content_rights_evidence_requires_configured_runtime_issuer_before_backend() { + let operation = protected_content_signed_operation(0); + let mut provider = provider_with_rights_rpc_without_runtime_issuer( + "http://127.0.0.1:9".to_string(), + "0x12345678", + ); + + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "runtime_issuer_not_configured" + ); +} + +#[test] +fn protected_content_rights_evidence_reinit_replaces_runtime_issuer_trust() { + let operation = protected_content_signed_operation(0); + let mut provider = provider_with_rights_rpc("http://127.0.0.1:9".to_string(), "0x12345678"); + + let malformed = provider.handle(Request::Init { + config: json!({ + "extra": { + "protected_content_runtime_issuer": "0xnothex" + } + }), }); + assert_eq!(error_code(malformed), "invalid_config"); + assert_eq!( + provider.protected_content_runtime_issuer, + Some(runtime_issuer(0x42)) + ); + + let cleared = provider.handle(Request::Init { + config: json!({ + "extra": {} + }), + }); + assert!(matches!(cleared, Response::Ok { .. })); + assert_eq!(provider.protected_content_runtime_issuer, None); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "runtime_issuer_not_configured" + ); +} + +#[test] +fn protected_content_rights_evidence_rejects_bad_signature_issuer_and_window_before_backend() { + let operation = protected_content_signed_operation(0); + let bad_signature_operation = + SignedRuntimeReleaseOperationV1::new(operation.statement().clone(), vec![0; 64]).unwrap(); + let wrong_issuer_operation = signed_runtime_operation_for_policy_and_runtime_seed( + protected_content_policy_and_request(0).0, + 0x43, + ); - assert_eq!(error_code(response), "rights_query_not_configured"); + for (label, signed_runtime_release_operation, now_unix_seconds) in [ + ( + "bad signature", + contract_hex(&bad_signature_operation), + rights_evidence_now as fn() -> u64, + ), + ( + "wrong issuer", + contract_hex(&wrong_issuer_operation), + rights_evidence_now, + ), + ( + "future operation", + contract_hex(&operation), + before_runtime_operation_window, + ), + ( + "expired operation", + contract_hex(&operation), + after_runtime_operation_window, + ), + ] { + let mut provider = provider_with_rights_rpc_at( + "http://127.0.0.1:9".to_string(), + "0x12345678", + now_unix_seconds, + ); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation, + })), + "invalid_runtime_operation", + "{label}" + ); + } } #[test] -fn has_access_by_content_id_calls_configured_typed_rights_abi() { +fn protected_content_rights_evidence_returns_canonical_evidence_at_observed_block() { + let operation = protected_content_signed_operation(3); + let policy = operation.statement().policy_body(); + let request = operation.statement().evidence_request(); let expected_data = encode_has_access_by_content_id_call( "0x12345678", - "bafybeigprotectedcontent", - "0x0000000000000000000000000000000000000002", - "view", + policy.content_id(), + &wallet_subject_hex(&operation), + policy.evm_right_argument(), ) .unwrap(); - let rpc_url = spawn_eth_call_server( - expected_data, - json!("0x0000000000000000000000000000000000000000000000000000000000000001"), + let block_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let rpc_url = spawn_rpc_sequence_asserting_server(vec![ + ("eth_chainId", json!([]), json!("0x14")), + ("eth_blockNumber", json!([]), json!("0x2a")), + ( + "eth_getBlockByNumber", + json!(["0x27", false]), + json!({ "number": "0x27", "hash": block_hash }), + ), + ( + "eth_call", + json!([ + { "to": "0x0000000000000000000000000000000000000001", "data": expected_data }, + { "blockHash": block_hash, "requireCanonical": true } + ]), + evm_bool_word(true), + ), + ]); + let mut provider = provider_with_rights_rpc_at(rpc_url, "0x12345678", || RIGHTS_EVIDENCE_NOW); + + let data = ok_data(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })); + + assert_eq!( + data["schema"], + "elastos.chain.protected-content-rights-evidence/v1" ); - let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + assert_eq!(data["chain_id"], 20); + assert_eq!(data["observed_block_number"], 39); + assert_eq!(data["head_block_number"], 42); + assert_eq!(data["observed_block_hash"], block_hash); + assert!(data.get("network").is_none()); + assert!(data.get("rpc_url").is_none()); + let evidence_hex = data["rights_evaluation_evidence"].as_str().unwrap(); + let evidence_bytes = decode_hex(evidence_hex, None, "rights_evaluation_evidence").unwrap(); + let evidence = RightsEvaluationEvidenceV1::from_canonical_bytes(&evidence_bytes).unwrap(); + evidence.validate_against_request(request, policy).unwrap(); + assert_eq!( + evidence.runtime_operation_hash(), + operation.statement().canonical_hash().unwrap() + ); + assert_eq!( + evidence.release_request_hash(), + operation + .statement() + .release_request() + .request_hash() + .unwrap() + ); + assert_eq!(evidence.acquired_at(), RIGHTS_EVIDENCE_NOW); + assert_eq!(evidence.expires_at(), RIGHTS_EVIDENCE_NOW + 30); + assert!(evidence.has_access()); + assert_eq!(evidence.observed_block_number(), 39); + assert_eq!(evidence.head_block_number(), 42); +} - let data = ok_data(provider.handle(Request::HasAccessByContentId { - network: "esc-local".to_string(), - contract: "0x0000000000000000000000000000000000000001".to_string(), - content_id: "bafybeigprotectedcontent".to_string(), - subject: "0x0000000000000000000000000000000000000002".to_string(), - right: "view".to_string(), +#[test] +fn protected_content_rights_evidence_captures_denial_without_caller_supplied_result() { + let operation = protected_content_signed_operation(0); + let policy = operation.statement().policy_body(); + let expected_data = encode_has_access_by_content_id_call( + "0x12345678", + policy.content_id(), + &wallet_subject_hex(&operation), + policy.evm_right_argument(), + ) + .unwrap(); + let rpc_url = spawn_rpc_sequence_asserting_server(vec![ + ("eth_chainId", json!([]), json!("0x14")), + ("eth_blockNumber", json!([]), json!("0x2a")), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + json!({ "number": "0x2a", "hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }), + ), + ( + "eth_call", + json!([ + { "to": "0x0000000000000000000000000000000000000001", "data": expected_data }, + { "blockHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "requireCanonical": true } + ]), + evm_bool_word(false), + ), + ]); + let mut provider = provider_with_rights_rpc_at(rpc_url, "0x12345678", || RIGHTS_EVIDENCE_NOW); + + let data = ok_data(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), })); + let evidence_bytes = decode_hex( + data["rights_evaluation_evidence"].as_str().unwrap(), + None, + "rights_evaluation_evidence", + ) + .unwrap(); + let evidence = RightsEvaluationEvidenceV1::from_canonical_bytes(&evidence_bytes).unwrap(); + assert!(!evidence.has_access()); +} - assert_eq!(data["has_access"], true); - assert_eq!(data["right"], "view"); +#[test] +fn protected_content_rights_evidence_rejects_stale_or_mismatched_authority() { + let operation = protected_content_signed_operation(5); + let rpc_url = spawn_rpc_sequence_asserting_server(vec![ + ("eth_chainId", json!([]), json!("0x14")), + ("eth_blockNumber", json!([]), json!("0x3")), + ]); + let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "insufficient_finality" + ); } #[test] -fn has_access_by_content_id_rejects_unconfigured_contract_before_backend() { +fn protected_content_rights_evidence_rejects_wrong_chain_block_and_hash() { + let operation = protected_content_signed_operation(0); + let policy = operation.statement().policy_body(); + let wrong_chain_policy = RightsPolicyBodyV1::new( + policy.content_id(), + RightsActionV1::View, + policy.evm_right_argument(), + RightsSubjectSourceV1::WalletAddress, + 21, + policy.contract_address(), + policy.function_selector(), + policy.method_abi(), + policy.observation_finality(), + ) + .unwrap(); + let wrong_chain_operation = signed_runtime_operation_for_policy(wrong_chain_policy); let mut provider = provider_with_rights_rpc("http://127.0.0.1:9".to_string(), "0x12345678"); - let response = provider.handle(Request::HasAccessByContentId { - network: "esc-local".to_string(), - contract: "0x0000000000000000000000000000000000000003".to_string(), - content_id: "bafybeigprotectedcontent".to_string(), - subject: "0x0000000000000000000000000000000000000002".to_string(), - right: "view".to_string(), + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&wrong_chain_operation), + })), + "rights_query_not_configured" + ); + + let rpc_url = + spawn_rpc_sequence_asserting_server(vec![("eth_chainId", json!([]), json!("0x15"))]); + let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "chain_id_mismatch" + ); + + let rpc_url = spawn_rpc_sequence_asserting_server(vec![ + ("eth_chainId", json!([]), json!("0x14")), + ("eth_blockNumber", json!([]), json!("not-a-quantity")), + ]); + let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "upstream_invalid_head" + ); + + let rpc_url = spawn_rpc_sequence_asserting_server(vec![ + ("eth_chainId", json!([]), json!("0x14")), + ("eth_blockNumber", json!([]), json!("0x2a")), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + json!({ "number": "0x2b", "hash": "0x1234" }), + ), + ]); + let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "upstream_invalid_block" + ); + + let rpc_url = spawn_rpc_sequence_asserting_server(vec![ + ("eth_chainId", json!([]), json!("0x14")), + ("eth_blockNumber", json!([]), json!("0x2a")), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + json!({ "number": "0x2a", "hash": "0x1234" }), + ), + ]); + let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "upstream_invalid_block" + ); +} + +#[test] +fn protected_content_rights_evidence_rejects_reorged_observed_hash() { + let operation = protected_content_signed_operation(0); + let policy = operation.statement().policy_body(); + let expected_data = encode_has_access_by_content_id_call( + "0x12345678", + policy.content_id(), + &wallet_subject_hex(&operation), + policy.evm_right_argument(), + ) + .unwrap(); + let observed_hash = "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let rpc_url = spawn_rpc_sequence_asserting_server_with_replies(vec![ + ("eth_chainId", json!([]), RpcReply::Result(json!("0x14"))), + ( + "eth_blockNumber", + json!([]), + RpcReply::Result(json!("0x2a")), + ), + ( + "eth_getBlockByNumber", + json!(["0x2a", false]), + RpcReply::Result(json!({ "number": "0x2a", "hash": observed_hash })), + ), + ( + "eth_call", + json!([ + { "to": "0x0000000000000000000000000000000000000001", "data": expected_data }, + { "blockHash": observed_hash, "requireCanonical": true } + ]), + RpcReply::Error(json!({ + "code": -32001, + "message": "requested block hash is not canonical" + })), + ), + ]); + let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "upstream_rpc_error" + ); +} + +#[test] +fn protected_content_rights_evidence_rejects_malformed_or_oversized_contract_bytes() { + let mut provider = provider_with_rights_rpc("http://127.0.0.1:9".to_string(), "0x12345678"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: "0xABCDEF".to_string(), + })), + "invalid_runtime_operation" + ); + + let oversized = format!( + "0x{}", + "00".repeat(MAX_PROTECTED_CONTENT_RUNTIME_OPERATION_BYTES + 1) + ); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: oversized, + })), + "invalid_runtime_operation" + ); + + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: "0x00".to_string(), + })), + "invalid_runtime_operation" + ); +} + +#[test] +fn protected_content_rights_evidence_rejects_selector_mismatch_without_backend() { + let operation = protected_content_signed_operation(0); + let mut provider = provider_with_rights_rpc("http://127.0.0.1:9".to_string(), "0x87654321"); + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "rights_selector_mismatch" + ); +} + +#[test] +fn protected_content_rights_evidence_rejects_ambiguous_configured_sources() { + let operation = protected_content_signed_operation(0); + let mut provider = ChainProvider::new(); + let init = provider.handle(Request::Init { + config: json!({ + "extra": { + "protected_content_runtime_issuer": runtime_issuer_hex(0x42) + }, + "networks": [ + { + "id": "esc-a", + "display_name": "ESC A", + "kind": "evm_json_rpc", + "chain_id": 20, + "native_symbol": "ELA", + "provider": "test", + "mainnet": false, + "explorer_url": null, + "rpc_url": "http://127.0.0.1:9", + "rights_methods": [{ + "id": "has_access_by_content_id", + "contract": "0x0000000000000000000000000000000000000001", + "abi": "has_access_by_content_id_string_address_string", + "selector": "0x12345678" + }] + }, + { + "id": "esc-b", + "display_name": "ESC B", + "kind": "evm_json_rpc", + "chain_id": 20, + "native_symbol": "ELA", + "provider": "test", + "mainnet": false, + "explorer_url": null, + "rpc_url": "http://127.0.0.1:9", + "rights_methods": [{ + "id": "has_access_by_content_id", + "contract": "0x0000000000000000000000000000000000000001", + "abi": "has_access_by_content_id_string_address_string", + "selector": "0x12345678" + }] + } + ] + }), }); + assert!(matches!(init, Response::Ok { .. })); + provider.now_unix_seconds = rights_evidence_now; + assert_eq!( + error_code(provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + })), + "ambiguous_rights_evidence_source" + ); +} - assert_eq!(error_code(response), "rights_contract_not_allowed"); +#[test] +fn protected_content_rights_evidence_sanitizes_upstream_rpc_errors() { + let operation = protected_content_signed_operation(0); + let rpc_url = spawn_rpc_sequence_asserting_server_with_replies(vec![( + "eth_chainId", + json!([]), + RpcReply::Error(json!({ + "code": -32000, + "message": "http://user:secret@127.0.0.1:8545 leaked upstream body" + })), + )]); + let mut provider = provider_with_rights_rpc(rpc_url, "0x12345678"); + match provider.handle(Request::ProtectedContentRightsEvidence { + signed_runtime_release_operation: contract_hex(&operation), + }) { + Response::Error { code, message } => { + assert_eq!(code, "upstream_rpc_error"); + for forbidden in ["secret", "user", "127.0.0.1", "8545", "body"] { + assert!( + !message.contains(forbidden), + "provider error leaked {forbidden}: {message}" + ); + } + } + other => panic!("expected sanitized error, got {other:?}"), + } +} + +#[test] +fn init_rejects_rpc_url_userinfo_for_all_rpc_kinds() { + let mut provider = ChainProvider::new(); + let evm = provider.handle(Request::Init { + config: json!({ + "networks": [{ + "id": "esc-local", + "display_name": "ESC Local", + "kind": "evm_json_rpc", + "chain_id": 20, + "native_symbol": "ELA", + "provider": "test", + "mainnet": false, + "explorer_url": null, + "rpc_url": "http://user:secret@127.0.0.1:8545" + }] + }), + }); + assert_eq!(error_code(evm), "invalid_config"); + + let mut provider = ChainProvider::new(); + let bitcoin = provider.handle(Request::Init { + config: json!({ + "networks": [{ + "id": "btc-local", + "display_name": "BTC Local", + "kind": "bitcoin_core_rpc", + "chain_id": null, + "native_symbol": "BTC", + "provider": "Bitcoin Core", + "mainnet": false, + "explorer_url": null, + "rpc_url": "http://user:secret@127.0.0.1:18443" + }] + }), + }); + assert_eq!(error_code(bitcoin), "invalid_config"); +} + +#[test] +fn protected_content_rights_evidence_rejects_legacy_or_injected_fields() { + let operation = protected_content_signed_operation(0); + for value in [ + json!({ + "op": "protected_content_rights_evidence", + "signed_runtime_release_operation": contract_hex(&operation), + "observed_evidence": { "has_access": true } + }), + json!({ + "op": "protected_content_rights_evidence", + "signed_runtime_release_operation": contract_hex(&operation), + "has_access": true + }), + json!({ + "op": "protected_content_rights_evidence", + "network": "esc-local", + "signed_runtime_release_operation": contract_hex(&operation) + }), + json!({ + "op": "protected_content_has_access_by_content_id", + "signed_runtime_release_operation": contract_hex(&operation) + }), + json!({ + "op": "has_access_by_content_id", + "network": "esc-local", + "contract": "0x0000000000000000000000000000000000000001", + "content_id": "bafybeigprotectedcontent", + "subject": "0x0000000000000000000000000000000000000002", + "right": "view" + }), + ] { + assert!( + serde_json::from_value::(value).is_err(), + "protected-content chain request accepted caller-supplied evidence or legacy op" + ); + } } #[test] @@ -936,31 +1808,3 @@ fn init_rejects_rights_methods_on_non_evm_networks() { assert_eq!(error_code(response), "invalid_config"); } - -#[test] -fn has_access_by_content_id_rejects_invalid_right_before_backend() { - let mut provider = provider_with_rpc("http://127.0.0.1:9".to_string()); - let response = provider.handle(Request::HasAccessByContentId { - network: "esc-local".to_string(), - contract: "0x0000000000000000000000000000000000000001".to_string(), - content_id: "bafybeigprotectedcontent".to_string(), - subject: "0x0000000000000000000000000000000000000002".to_string(), - right: "raw_call".to_string(), - }); - - assert_eq!(error_code(response), "invalid_right"); -} - -#[test] -fn has_access_by_content_id_rejects_invalid_contract_before_backend() { - let mut provider = provider_with_rpc("http://127.0.0.1:9".to_string()); - let response = provider.handle(Request::HasAccessByContentId { - network: "esc-local".to_string(), - contract: "0x1234".to_string(), - content_id: "bafybeigprotectedcontent".to_string(), - subject: "0x0000000000000000000000000000000000000002".to_string(), - right: "view".to_string(), - }); - - assert_eq!(error_code(response), "invalid_contract"); -} diff --git a/capsules/chain-provider/src/tests/support.rs b/capsules/chain-provider/src/tests/support.rs index f25232d2a..5611faa9a 100644 --- a/capsules/chain-provider/src/tests/support.rs +++ b/capsules/chain-provider/src/tests/support.rs @@ -100,6 +100,86 @@ pub(super) fn spawn_rpc_sequence_server(responses: Vec<(&'static str, Value)>) - format!("http://{addr}") } +pub(super) enum RpcReply { + Result(Value), + Error(Value), +} + +pub(super) fn spawn_rpc_sequence_asserting_server( + responses: Vec<(&'static str, Value, Value)>, +) -> String { + spawn_rpc_sequence_asserting_server_with_replies( + responses + .into_iter() + .map(|(method, params, result)| (method, params, RpcReply::Result(result))) + .collect(), + ) +} + +pub(super) fn spawn_rpc_sequence_asserting_server_with_replies( + responses: Vec<(&'static str, Value, RpcReply)>, +) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + thread::spawn(move || { + for (expected_method, expected_params, reply) in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buf = [0u8; 1024]; + let body_start = loop { + let n = stream.read(&mut buf).unwrap(); + if n == 0 { + panic!("connection closed before headers"); + } + request.extend_from_slice(&buf[..n]); + if let Some(pos) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break pos + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..body_start]); + let content_length = headers + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length:") + .or_else(|| line.strip_prefix("content-length:")) + }) + .and_then(|value| value.trim().parse::().ok()) + .expect("request must include Content-Length"); + while request.len() < body_start + content_length { + let n = stream.read(&mut buf).unwrap(); + if n == 0 { + break; + } + request.extend_from_slice(&buf[..n]); + } + let body = &request[body_start..body_start + content_length]; + let rpc: Value = serde_json::from_slice(body).unwrap(); + assert_eq!(rpc["method"], expected_method); + assert_eq!(rpc["params"], expected_params); + let body = match reply { + RpcReply::Result(result) => json!({ + "jsonrpc": "2.0", + "id": 1, + "result": result, + }), + RpcReply::Error(error) => json!({ + "jsonrpc": "2.0", + "id": 1, + "error": error, + }), + } + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + format!("http://{addr}") +} + pub(super) fn spawn_eth_call_server(expected_data: String, result: Value) -> String { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/capsules/chain-provider/src/validation.rs b/capsules/chain-provider/src/validation.rs index d788af075..cae44702a 100644 --- a/capsules/chain-provider/src/validation.rs +++ b/capsules/chain-provider/src/validation.rs @@ -1,4 +1,7 @@ use super::*; +use elastos_protected_content_contracts::{ + CanonicalContract, Digest32, RuntimeOperationIssuerKeyV1, +}; use sha2::Digest; use std::time::{SystemTime, UNIX_EPOCH}; @@ -40,19 +43,6 @@ pub(super) fn validate_evm_hash(value: &str) -> Result<(), String> { validate_hex(value, Some(32), "EVM hash") } -pub(super) fn validate_content_id(value: &str) -> Result<(), String> { - if value.is_empty() || value.len() > 256 { - return Err("content id must be 1-256 characters".to_string()); - } - if value - .chars() - .any(|ch| ch.is_ascii_whitespace() || ch.is_ascii_control() || ch == '/' || ch == '\\') - { - return Err("content id must be an opaque CID or content identifier".to_string()); - } - Ok(()) -} - pub(super) fn validate_subject(value: &str) -> Result<(), String> { if value.is_empty() || value.len() > 256 { return Err("subject must be 1-256 characters".to_string()); @@ -66,13 +56,6 @@ pub(super) fn validate_subject(value: &str) -> Result<(), String> { Ok(()) } -pub(super) fn validate_right(value: &str) -> Result<(), String> { - match value { - "view" | "stream" | "download" | "execute" => Ok(()), - _ => Err("right must be view, stream, download, or execute".to_string()), - } -} - pub(super) fn validate_hex(value: &str, bytes: Option, label: &str) -> Result<(), String> { let raw = value .strip_prefix("0x") @@ -134,6 +117,66 @@ pub(super) fn decode_hex( Ok(decoded) } +pub(super) fn decode_contract_hex( + value: &str, + max_bytes: usize, + label: &str, +) -> Result { + if value.strip_prefix("0x").is_none_or(|raw| { + raw.chars() + .any(|ch| !ch.is_ascii_lowercase() && !ch.is_ascii_digit()) + }) { + return Err(format!("{label} must be lowercase 0x-prefixed hex")); + } + let bytes = decode_hex(value, None, label)?; + if bytes.is_empty() || bytes.len() > max_bytes { + return Err(format!("{label} size is invalid")); + } + T::from_canonical_bytes(&bytes).map_err(|err| err.to_string()) +} + +pub(super) fn parse_runtime_issuer(value: &Value) -> Result { + let value = value + .as_str() + .ok_or_else(|| "protected-content Runtime issuer must be a string".to_string())?; + if value.strip_prefix("0x").is_none_or(|raw| { + raw.chars() + .any(|ch| !ch.is_ascii_lowercase() && !ch.is_ascii_digit()) + }) { + return Err( + "protected-content Runtime issuer must be lowercase 0x-prefixed hex".to_string(), + ); + } + let bytes = decode_hex(value, Some(32), "protected-content Runtime issuer")?; + RuntimeOperationIssuerKeyV1::new( + bytes + .try_into() + .map_err(|_| "protected-content Runtime issuer must be 32 bytes".to_string())?, + ) + .map_err(|_| "protected-content Runtime issuer is invalid".to_string()) +} + +pub(super) fn evm_observed_block(value: &Value, expected_number: u64) -> Result { + let number = value + .get("number") + .and_then(Value::as_str) + .ok_or_else(|| "EVM block number missing".to_string()) + .and_then(|value| { + parse_hex_u64(value).map_err(|_| "EVM block number must be a hex quantity".to_string()) + })?; + if number != expected_number { + return Err("EVM block number does not match requested observation".to_string()); + } + let hash = value + .get("hash") + .and_then(Value::as_str) + .ok_or_else(|| "EVM block hash missing".to_string())?; + let bytes = decode_hex(hash, Some(32), "EVM block hash")?; + Ok(Digest32::new(bytes.try_into().map_err(|_| { + "EVM block hash must be 32 bytes".to_string() + })?)) +} + pub(super) fn hex_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index a576a62e4..6cd38cbe5 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -4,13 +4,12 @@ Protected content is Runtime-mediated. App, viewer, and content capsules ask to open an object; they do not receive raw wallet, chain, IPFS, Elacity, or key authority. -The canonical published source-only review stack currently ends at -`origin/feat/protected-content-key-reconstruction` at `a8d0a9a6`. Released -0.6 still contains the older provisional provider surface, and newer local -child branches extend the source-only review line without changing published or -installed product truth: +The protected-content source line is still not installed product behavior. +Released 0.6 contains the older provisional provider surface. Newer review +branches define the canonical source boundaries without changing the active +Library/open path: -- The canonical v1 published review stack is the +- The canonical v1 published review stack begins with the `elastos-protected-content-contracts` branch, the companion `elastos-protected-content-custody` branch, and the published child `feat/protected-content-key-reconstruction` branch, documented in @@ -22,22 +21,35 @@ installed product truth: a claim-gated node release path, and source-only custody helpers for custody-envelope provisioning, recipient-sealed node release, and threshold reconstruction inside the decrypt boundary for new content. It is - not yet wired into Runtime orchestration, provider integration, - Runtime-owned replay storage, recipient key-possession proof, decryption, - playback, installation, or deployment. + not yet wired into product Runtime orchestration, provider integration, + Runtime-owned replay storage, recipient key-possession proof, playback, + installation, or deployment. +- The current published source-only review line also includes + `origin/feat/protected-content-custody-provider` at `f7cd6c3d`. Its ancestry + adds the private provider protocol, authenticated payload sealing, local + decrypt-output helper, object-bound custody-pool policy, one-node + provisioning authority, expected Runtime issuer pinning, owner-only durable + node-share storage, and an unregistered `custody-provider` process. It proves + one selected node stores one sealed share and releases only recipient-sealed + contributions after signed rights validation. It is not active, registered, + installed, or product-proven. +- The current local full-stack source planning line lives on + `feat/protected-content-rights`. Its latest code commit before this docs-only + planning commit is `a32ae85a`. The relevant local code commits are + Wallet-rights at `2c69d0c2`, Runtime coordinator at `b00bfeeb`, Chain + evidence at `7c747253`, and rights evaluator at `a32ae85a`. These are + source-only branches; they do not register providers, alter Library, or + replace the installed provisional path. - The older installed/provider surface is the provisional `elastos_common::protected_content` DTO set plus the fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` capsules. It is not current architecture and does not consume or prove the - v1 contract. The Runtime integration stage must remove it atomically. It must - not remain as a parallel decoder or compatibility path. -- The local `feat/protected-content-custody-pool` branch is source-only and - unpublished. It is a policy-contract child branch for review, not installed - product behavior. -- The local `feat/protected-content-custody-provider` branch is source-only, - unpublished, and unregistered. It proves the next custody-node provider - boundary without changing installed product behavior or replacing the active - provisional `key-provider`. + v1 contract. The full inactive canonical mint/buy/open/play path must be + implemented and proven before the Runtime cutover removes this surface + atomically. It must not remain as a parallel decoder or compatibility path. +- Local descendants above the published custody-provider line remain + unpublished source work until explicitly pushed for review. None of them is + installed product behavior. ## Canonical architecture @@ -83,18 +95,22 @@ configuration. Contracts and capsules see Runtime-selected service or endpoint identities, never raw topology. Carrier transports selected endpoint traffic and does not grant rights or custody authority. -This architecture is not yet installed or wired into Runtime. Published source -truth is source-only through contracts, custody behavior, and -decrypt-boundary reconstruction. The new protected-content product path is not +This architecture is not yet installed or wired into the active Library product +path. Source truth now includes contracts, custody behavior, authenticated +payload sealing, decrypt-boundary helpers, the unregistered custody-provider +process, Wallet-rights signing, a private Runtime coordination foundation, and +typed chain-rights evaluation. The new protected-content product path is not yet connected or usable. -Current source tests prove canonical contracts, node release, exact-threshold -reconstruction, tamper/expiry/wrong-binding rejection, commitment checking, and -CEK zeroization. PR #15 contains provider and development-harness encryption and -decryption evidence. No accepted test yet proves +Current source tests prove canonical contracts, node release, one-node custody +storage/release, Wallet-rights signing, exact chain-rights evidence, typed +Runtime internal coordination, exact-threshold reconstruction, +tamper/expiry/wrong-binding rejection, commitment checking, and CEK +zeroization. PR #15 contains provider and development-harness encryption and +decryption evidence. No accepted installed test yet proves `mint -> Runtime authority/provider selection/audit -> per-node custody release -> private reconstruction/decryption -> plaintext/playback` through the real -Runtime product path. +Runtime product path with three distinct custody providers. ## Provisional retirement surface @@ -130,10 +146,11 @@ behavior evidence only: capsules or contracts, static authorization fallbacks, and the standalone harness as a product route. -PR #15's generated `escrow.json` is historical development evidence only. It -aggregates wrapped shares and its writer omits `cek_commitment_b64` while the -reload path requires it, so ElastOS must not adopt that fixture as canonical -metadata. +PR #15's producer-smoke `escrow.json` is historical development evidence only. +It aggregates wrapped shares, so ElastOS must not adopt that fixture as +canonical metadata. The producer smoke writes and reloads +`cek_commitment_b64`; the missing-commitment writer/reloader inconsistency +belongs to the older Creator path. ## Future sealed decrypt handoff @@ -179,24 +196,24 @@ protected content: bound threshold count. Required-plus-one contributions are rejected. - It returns only opaque, redacted secret wrappers; it does not expose a capsule-visible raw-key API. -- The local unpublished `feat/protected-content-payload-sealing` child branch - adds source-only staged payload sealing inside this same custody crate. That - is intentional: keeping CEK generation, commitment, payload encryption, - custody-envelope provisioning, and zeroization in one crypto boundary is - safer than exposing or duplicating CEK APIs across crates. The sealing API - writes only to a staging sink and returns canonical metadata only after both - ciphertext staging and custody provisioning succeed; callers must discard - staged output after any error. -- The local unpublished `feat/protected-content-decrypt-output` child branch - extends that same custody boundary with source-only staged decrypt output. - It accepts the exact encrypted-content identity plus the existing - authenticated release inputs, reconstructs the CEK only inside custody, - verifies the full framed ciphertext identity before any plaintext write, - authenticates each chunk before staging plaintext, and returns only bounded - plaintext metadata after full success. On any error it returns no success - metadata and callers must discard staged plaintext output. It does not yet - add provider wire, Runtime orchestration, viewer output protocol, - installation, or deployment. +- The published custody-provider review line includes source-only staged + payload sealing inside this same custody crate. That is intentional: keeping + CEK generation, commitment, payload encryption, custody-envelope + provisioning, and zeroization in one crypto boundary is safer than exposing + or duplicating CEK APIs across crates. The sealing API writes only to a + staging sink and returns canonical metadata only after both ciphertext + staging and custody provisioning succeed; callers must discard staged output + after any error. +- The published custody-provider review line also includes source-only staged + decrypt output inside that custody boundary. It accepts the exact + encrypted-content identity plus the existing authenticated release inputs, + reconstructs the CEK only inside custody, verifies the full framed + ciphertext identity before any plaintext write, authenticates each chunk + before staging plaintext, and returns only bounded plaintext metadata after + full success. On any error it returns no success metadata and callers must + discard staged plaintext output. It does not yet add provider wire, Runtime + orchestration, viewer streaming, product integration, installation, or + deployment. - Custody-pool policy keeps three truths separate: a permissioned signed custody pool for node eligibility, each immutable per-object custody committee in `SignedCustodyEpochV1`, and later Runtime route resolution @@ -236,6 +253,17 @@ contract layer that later Runtime and custody-node integrations must consume: contract right string, the product action that policy maps to that right, one bounded confirmation rule, and exact observed block/result evidence. They do not carry RPC URLs, provider labels, or routes. +- The local chain-rights/evaluator branch tightens this into a source-only + typed evidence path: evidence is acquired for the exact Runtime release + operation, verifies live chain id, uses exact block hash plus block number, + rejects selector/method mismatches, applies bounded freshness, and redacts + upstream provider failures. The evaluator obtains evidence through a trusted + source handle rather than accepting arbitrary caller-supplied rights facts. +- The local Wallet-rights branch adds one dedicated Wallet operation that signs + exact canonical `RightsRequestV1` bytes for the selected active EVM account + through the existing verified Wallet invocation context. It carries no + duplicate object, pool, epoch, committee, Profile, or session fields outside + the canonical request. - `SignedRecipientKeyAuthorizationV1` lets the authenticated Profile authorize one exact recipient public key and one exact Runtime application-operation issuer for one protected-content binding, action, session, and time window. @@ -262,12 +290,21 @@ contract layer that later Runtime and custody-node integrations must consume: shutdown. It is not registered by Runtime, installed, deployed, or product proven. It carries no CEK, raw share, provider route, endpoint, IP address, port, Carrier topology, or credential in provider responses. +- The local Runtime coordinator branch adds private durable operation state and + typed internal coordination over existing Wallet, rights, and custody + provider contracts. It persists before provider effects, records + effect-started state before the first effectful call, stores exact terminal + results for replay, and leaves ambiguous post-dispatch outcomes durable and + nonterminal. Runtime/custody reconciliation for claims or operations that + survive a crash after provider effects but before a terminal result is still + required implementation work. It is not wired into `elastos-server` routes, + Library, viewer output, provider startup, installation, or deployment. This operational layer is still source-only. There is no provider registry -cutover, Runtime-selected custody-provider lifecycle, Runtime-owned replay -store, recipient key-possession proof, installed product flow, or production -confidentiality claim in this branch. The provisional `key-provider` remains the -only active registered product path until the atomic cutover. +cutover, Runtime-selected custody-provider lifecycle, recipient key-possession +proof, installed product flow, or production confidentiality claim in this +branch. The provisional `key-provider` remains the only active registered +product path until the atomic cutover. ## Capsule boundary @@ -287,33 +324,62 @@ and coordinate the typed rights, custody, and decrypt operations. ## Remaining sequence -A. Finish the source-only review gate: professional external cryptographic and - contract review, plus any additional grounded HPKE wrapper known-answer - coverage that a reviewed upstream vector path can support. -B. Continue the source-only custody-node operations and durable node-state - layer around the reviewed contracts. The published custody branch adds the - durable dual-key claim boundary, claim-gated node release, and exact encrypted - result replay. Remaining work is node admission, rotation, revocation, - recovery, issuer-key lifecycle, operational audit retention, recovery - from a claim that completed before its result became durable, and Runtime - provider lifecycle/route resolution keyed by `node_public_key`. The local - unpublished custody-provider branch has a source-only unregistered provider - process, but no Runtime registration or product cutover. PR #15 is public - source evidence only for later UX and node-operation extraction; its direct - network wiring and logger proof of concept are explicitly excluded from the - canonical path. -C. Replace the provisional `elastos_common::protected_content` DTO/provider - surface atomically with Runtime-owned orchestration over the reviewed v1 - contract: Wallet integration, recipient key generation and possession proof, - typed rights checks, rights-bound key release, release-receipt-bound - decrypt/render sessions, sealed decrypt material, and one source allow/deny - proof with no fallback path. -D. Prove the installed end-to-end path: real protected-content producers, - installed provider/runtime flow, decrypt/render handoff, and final product - evidence. - -Only after A-D should ElastOS decide whether a permissioned PQ-hybrid dKMS -layer should replace the pinned source-only helper for new content. +Before a local source-only child branch is used as a parent, its branch ancestry +and focused gates still need review/publish hygiene. The product implementation +order is: + +1. Implement the inactive Runtime provider lifecycle, registration, routing, and + durable reconciliation seam before or in the same slice as the typed + rights-provider process adapter. Runtime must own provider selection, + readiness, registration, durable journal, audit, retry, terminal settlement, + and exact identity-bound recovery for durable claims or operations that + survive a crash after provider effects but before terminal result. Retirement + requires terminal receipts, not time, path absence, provider absence, or + fallback truth. +2. Add a typed rights-provider process adapter through that seam only if it can + obtain Chain evidence through a Runtime-owned/provider-registry boundary. Do + not introduce direct RPC, caller-supplied evidence, static evidence, or + topology in the provider protocol. +3. Add recipient possession and decrypt-session authority. Profile authorization + of a recipient public key is not enough by itself; the decrypt boundary must + prove the intended recipient controls the key before reconstructing or using + the CEK. +4. Build the Runtime-owned producer mint journal/state machine for one media + flow: stage ciphertext and object bytes, bind exact object/envelope/pool/ + epoch/committee/node set/threshold/policy/viewer/availability facts, record + exact per-stage receipts, provision shares to all selected custody nodes, + recover after restart, commit availability, then list. On partial custody + provisioning, Runtime records a durable terminal mint abort, never lists or + exposes the object, and retains accepted orphan shares as unreachable by any + valid release operation. Later bounded garbage collection requires a + separately reviewed signed custody retirement operation with terminal + receipts; that operation does not exist now. Product completion must either + implement that exact retirement operation before installed acceptance, or + prove bounded orphan retention is the accepted first-release policy. Listing + must never commit before ciphertext/object availability and required custody + provisioning are durable. The first proof must use three distinct + custody-provider identities and state roots for 2-of-3; a one-node process + test is source proof for the provider boundary, not a product custody + topology. +5. Connect real Wallet purchase, exact Chain evidence, Runtime open, private + reconstruction/decryption, and opaque scoped viewer output. Approval and + broadcast use the existing durable Runtime transaction coordinator; Home + launch tokens remain HTTP-edge credentials, not Wallet or protected-content + authority. The output handle must be short-lived and bound to the exact + principal, launch or Runtime session, viewer, object, action, and expiry. Do + not specify a bearer playback URL. +6. Prove the full inactive end-to-end path: mint -> buy -> open -> play, with + three custody providers, allow/deny, tamper, wrong object, wrong/missing/ + duplicate/malicious contribution, replay, durable reconciliation, + restart/crash, cleanup, terminal receipts, and no CEK/share/topology leakage. +7. Atomically replace and remove the provisional `drm`/`key`/decrypt authority + only after the complete inactive replacement path is proven. No fallback, + dual authority, compatibility decoder, or capsule-selected provider path is + allowed. +8. Ship the minimum one-media UI and installed two-principal acceptance proof. + +Only after those gates should ElastOS decide whether a permissioned PQ-hybrid +dKMS layer should replace the pinned source-only helper for new content. Visible protected-content UI may ship only as a disabled/read-only readiness rail until fail-closed provider tests and capability-resource checks cover the diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index e447262bb..a26c3cf12 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -1697,6 +1697,20 @@ dependencies = [ "sha3", ] +[[package]] +name = "elastos-protected-content-rights" +version = "0.6.0" +dependencies = [ + "ed25519-dalek 2.2.0", + "elastos-auth", + "elastos-protected-content-contracts", + "elastos-protected-content-provider-contracts", + "k256", + "serde_json", + "sha3", + "thiserror 1.0.69", +] + [[package]] name = "elastos-protected-content-runtime" version = "0.6.0" diff --git a/elastos/Cargo.toml b/elastos/Cargo.toml index 217aff700..e15ffad16 100644 --- a/elastos/Cargo.toml +++ b/elastos/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/elastos-protected-content-custody", "crates/elastos-protected-content-provider-contracts", "crates/elastos-protected-content-runtime", + "crates/elastos-protected-content-rights", "crates/elastos-storage", "crates/elastos-compute", "crates/elastos-crosvm", diff --git a/elastos/crates/elastos-protected-content-contracts/src/lib.rs b/elastos/crates/elastos-protected-content-contracts/src/lib.rs index 5dd338a6d..ec42b0785 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/lib.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/lib.rs @@ -71,7 +71,7 @@ pub use node_set::{NodePublicKey, NodeSetV1}; pub use policy::{ EvmContractAddressV1, EvmFunctionSelectorV1, EvmRightsMethodAbiV1, RightsEvaluationEvidenceRequestV1, RightsEvaluationEvidenceV1, RightsObservationFinalityV1, - RightsPolicyBodyV1, RightsSubjectSourceV1, + RightsPolicyBodyV1, RightsSubjectSourceV1, MAX_RIGHTS_EVIDENCE_LIFETIME_SECS, }; pub use recipient_authorization::{ RecipientAuthorizationError, RecipientKeyAuthorizationContextV1, diff --git a/elastos/crates/elastos-protected-content-contracts/src/policy.rs b/elastos/crates/elastos-protected-content-contracts/src/policy.rs index d95d0ec00..38721ea57 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/policy.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/policy.rs @@ -1,13 +1,15 @@ use serde::Serialize; use crate::canonical::{validate_ascii_identifier, CanonicalBody, ContractError, Decoder, Encoder}; +use crate::rights::{validate_active, validate_time_window}; use crate::{ - CanonicalContract, Digest32, ProtectedContentBindingV1, RightsActionV1, RightsPolicyIdentityV1, - WalletAddress, + AuthenticatedRuntimeReleaseOperationV1, CanonicalContract, Digest32, ProtectedContentBindingV1, + RightsActionV1, RightsPolicyIdentityV1, WalletAddress, }; const MAX_POLICY_IDENTIFIER_BYTES: usize = 256; const MAX_EVM_RIGHT_ARGUMENT_BYTES: usize = "download".len(); +pub const MAX_RIGHTS_EVIDENCE_LIFETIME_SECS: u64 = 30; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[serde(transparent)] @@ -306,6 +308,8 @@ impl CanonicalBody for RightsEvaluationEvidenceRequestV1 { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RightsEvaluationEvidenceV1 { + runtime_operation_hash: Digest32, + release_request_hash: Digest32, binding: ProtectedContentBindingV1, policy_identity: RightsPolicyIdentityV1, subject_wallet: WalletAddress, @@ -314,11 +318,15 @@ pub struct RightsEvaluationEvidenceV1 { observed_block_hash: Digest32, head_block_number: u64, has_access: bool, + acquired_at: u64, + expires_at: u64, } impl RightsEvaluationEvidenceV1 { #[allow(clippy::too_many_arguments)] pub fn new( + runtime_operation_hash: Digest32, + release_request_hash: Digest32, binding: ProtectedContentBindingV1, policy_identity: RightsPolicyIdentityV1, subject_wallet: WalletAddress, @@ -327,8 +335,12 @@ impl RightsEvaluationEvidenceV1 { observed_block_hash: Digest32, head_block_number: u64, has_access: bool, + acquired_at: u64, + expires_at: u64, ) -> Result { let value = Self { + runtime_operation_hash, + release_request_hash, binding, policy_identity, subject_wallet, @@ -337,11 +349,21 @@ impl RightsEvaluationEvidenceV1 { observed_block_hash, head_block_number, has_access, + acquired_at, + expires_at, }; value.validate()?; Ok(value) } + pub const fn runtime_operation_hash(&self) -> Digest32 { + self.runtime_operation_hash + } + + pub const fn release_request_hash(&self) -> Digest32 { + self.release_request_hash + } + pub fn binding(&self) -> &ProtectedContentBindingV1 { &self.binding } @@ -374,6 +396,14 @@ impl RightsEvaluationEvidenceV1 { self.has_access } + pub const fn acquired_at(&self) -> u64 { + self.acquired_at + } + + pub const fn expires_at(&self) -> u64 { + self.expires_at + } + pub fn validate_against_request( &self, request: &RightsEvaluationEvidenceRequestV1, @@ -402,6 +432,32 @@ impl RightsEvaluationEvidenceV1 { } Ok(()) } + + pub fn validate_against_runtime_release_at( + &self, + operation: &AuthenticatedRuntimeReleaseOperationV1, + now: u64, + ) -> Result<(), ContractError> { + self.validate_against_request( + operation.statement().evidence_request(), + operation.statement().policy_body(), + )?; + if self.runtime_operation_hash != operation.operation_hash() { + return Err(ContractError::InvalidField( + "evidence.runtime_operation_hash", + )); + } + if self.release_request_hash != operation.release_request_hash() { + return Err(ContractError::InvalidField("evidence.release_request_hash")); + } + if self.acquired_at < operation.statement().issued_at() + || self.expires_at > operation.statement().expires_at() + { + return Err(ContractError::InvalidField("evidence.window")); + } + validate_active(self.acquired_at, self.expires_at, now) + .map_err(|_| ContractError::InvalidField("evidence.window")) + } } impl CanonicalBody for RightsEvaluationEvidenceV1 { @@ -410,6 +466,12 @@ impl CanonicalBody for RightsEvaluationEvidenceV1 { fn validate(&self) -> Result<(), ContractError> { self.binding.canonical_bytes()?; self.policy_identity.canonical_bytes()?; + validate_time_window( + self.acquired_at, + self.expires_at, + MAX_RIGHTS_EVIDENCE_LIFETIME_SECS, + "rights_evaluation_evidence_lifetime", + )?; if self.binding.rights_policy() != &self.policy_identity { return Err(ContractError::InvalidField("evidence.binding")); } @@ -429,6 +491,8 @@ impl CanonicalBody for RightsEvaluationEvidenceV1 { } fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(self.runtime_operation_hash.as_bytes()); + encoder.fixed(self.release_request_hash.as_bytes()); encoder.nested(&self.binding)?; encoder.nested(&self.policy_identity)?; encoder.fixed(self.subject_wallet.as_bytes()); @@ -437,11 +501,15 @@ impl CanonicalBody for RightsEvaluationEvidenceV1 { encoder.fixed(self.observed_block_hash.as_bytes()); encoder.u64(self.head_block_number); encoder.u8(u8::from(self.has_access)); + encoder.u64(self.acquired_at); + encoder.u64(self.expires_at); Ok(()) } fn decode_fields(decoder: &mut Decoder<'_>) -> Result { Self::new( + Digest32::new(decoder.fixed()?), + Digest32::new(decoder.fixed()?), decoder.nested("binding")?, decoder.nested("policy_identity")?, WalletAddress::new(decoder.fixed()?), @@ -450,6 +518,8 @@ impl CanonicalBody for RightsEvaluationEvidenceV1 { Digest32::new(decoder.fixed()?), decoder.u64()?, decode_bool(decoder, "has_access")?, + decoder.u64()?, + decoder.u64()?, ) } } @@ -579,6 +649,8 @@ mod tests { request.validate_against_policy(&policy).unwrap(); let evidence = RightsEvaluationEvidenceV1::new( + digest(0x90), + digest(0x91), binding.clone(), policy.policy_identity().unwrap(), binding.wallet(), @@ -587,6 +659,8 @@ mod tests { digest(0x55), 112, true, + 1_000, + 1_030, ) .unwrap(); evidence @@ -614,6 +688,8 @@ mod tests { ) .unwrap(); let wrong_wallet = RightsEvaluationEvidenceV1::new( + digest(0x90), + digest(0x91), binding.clone(), policy.policy_identity().unwrap(), wallet(8), @@ -622,6 +698,8 @@ mod tests { digest(0x55), 112, true, + 1_000, + 1_030, ) .unwrap_err(); assert_eq!( @@ -630,6 +708,8 @@ mod tests { ); let wrong_chain = RightsEvaluationEvidenceV1::new( + digest(0x90), + digest(0x91), binding.clone(), policy.policy_identity().unwrap(), binding.wallet(), @@ -638,6 +718,8 @@ mod tests { digest(0x55), 112, true, + 1_000, + 1_030, ) .unwrap(); assert_eq!( @@ -646,6 +728,8 @@ mod tests { ); let low_finality = RightsEvaluationEvidenceV1::new( + digest(0x90), + digest(0x91), binding, policy.policy_identity().unwrap(), wallet(7), @@ -654,6 +738,8 @@ mod tests { digest(0x55), 111, true, + 1_000, + 1_030, ) .unwrap(); assert_eq!( diff --git a/elastos/crates/elastos-protected-content-rights/Cargo.toml b/elastos/crates/elastos-protected-content-rights/Cargo.toml new file mode 100644 index 000000000..0e3dc9993 --- /dev/null +++ b/elastos/crates/elastos-protected-content-rights/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "elastos-protected-content-rights" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Source-only protected-content rights evaluation boundary" + +[dependencies] +ed25519-dalek = "2.1" +elastos-protected-content-contracts = { path = "../elastos-protected-content-contracts" } +elastos-protected-content-provider-contracts = { path = "../elastos-protected-content-provider-contracts" } +thiserror = "1.0" + +[dev-dependencies] +elastos-auth = { path = "../elastos-auth" } +k256 = { version = "0.13", features = ["ecdsa"] } +serde_json = "1.0" +sha3 = "0.10" diff --git a/elastos/crates/elastos-protected-content-rights/src/lib.rs b/elastos/crates/elastos-protected-content-rights/src/lib.rs new file mode 100644 index 000000000..75a601e40 --- /dev/null +++ b/elastos/crates/elastos-protected-content-rights/src/lib.rs @@ -0,0 +1,779 @@ +#![forbid(unsafe_code)] + +//! Source-only protected-content rights evaluation boundary. +//! +//! Runtime selects the provider outside this crate. This crate consumes one +//! already authenticated Runtime release request plus a trusted evidence-source +//! handle and returns the existing signed node decision response. It does not +//! carry routes, topology, CEKs, or custody shares. + +use ed25519_dalek::{Signer as _, SigningKey}; +use elastos_protected_content_contracts::{ + CanonicalContract, ContractError, KeyReleaseError, NodePublicKey, + NodeRightsDecisionStatementV1, RightsDecisionV1, RightsEvaluationEvidenceV1, + SignedNodeRightsDecisionV1, MAX_NODE_DECISION_LIFETIME_SECS, +}; +use elastos_protected_content_provider_contracts::{ + RightsProviderResponseV1, ValidatedRightsProviderRequestV1, +}; +use std::{future::Future, pin::Pin}; +use thiserror::Error; + +pub type RightsEvidenceFutureV1<'a> = Pin< + Box< + dyn Future> + + Send + + 'a, + >, +>; + +pub trait TrustedRightsEvidenceSourceV1 { + fn acquire_evidence_at( + &self, + request: &ValidatedRightsProviderRequestV1, + now_unix_seconds: u64, + ) -> RightsEvidenceFutureV1<'_>; +} + +pub struct ProtectedContentRightsEvaluatorV1 { + node_public_key: NodePublicKey, + node_signing_key: SigningKey, + evidence_source: S, +} + +impl core::fmt::Debug for ProtectedContentRightsEvaluatorV1 { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter + .debug_struct("ProtectedContentRightsEvaluatorV1") + .field("node_public_key", &self.node_public_key) + .finish_non_exhaustive() + } +} + +impl ProtectedContentRightsEvaluatorV1 { + pub fn new( + node_signing_key: SigningKey, + evidence_source: S, + ) -> Result { + let node_public_key = NodePublicKey::new(node_signing_key.verifying_key().to_bytes()) + .map_err(|_| RightsEvaluationErrorV1::InvalidNodeSigningKey)?; + Ok(Self { + node_public_key, + node_signing_key, + evidence_source, + }) + } + + pub const fn node_public_key(&self) -> NodePublicKey { + self.node_public_key + } + + pub async fn evaluate_at( + &self, + request: &ValidatedRightsProviderRequestV1, + now_unix_seconds: u64, + ) -> Result { + if request.selected_node_public_key() != self.node_public_key { + return Err(RightsEvaluationErrorV1::WrongSelectedNode); + } + let authenticated = request.authenticated_runtime_release_operation(); + let statement = authenticated.statement(); + let evidence = self + .evidence_source + .acquire_evidence_at(request, now_unix_seconds) + .await?; + evidence + .validate_against_runtime_release_at(authenticated, now_unix_seconds) + .map_err(|_| RightsEvaluationErrorV1::EvidenceBinding)?; + let max_expires_at = now_unix_seconds + .checked_add(MAX_NODE_DECISION_LIFETIME_SECS) + .ok_or(RightsEvaluationErrorV1::DecisionWindow)?; + let expires_at = max_expires_at + .min(statement.expires_at()) + .min(statement.release_request().expires_at()); + if expires_at <= now_unix_seconds { + return Err(RightsEvaluationErrorV1::DecisionWindow); + } + let decision = if evidence.has_access() { + RightsDecisionV1::Allowed + } else { + RightsDecisionV1::Denied + }; + let decision_statement = NodeRightsDecisionStatementV1::new( + authenticated.release_request_hash(), + authenticated.rights_request_hash(), + authenticated.binding().clone(), + authenticated.action(), + self.node_public_key, + decision, + evidence.canonical_hash()?, + now_unix_seconds, + expires_at, + )?; + let signed_decision = SignedNodeRightsDecisionV1::new( + decision_statement.clone(), + self.node_signing_key + .sign(&decision_statement.canonical_bytes()?) + .to_bytes() + .to_vec(), + )?; + let node_set = statement + .custody_epoch() + .statement() + .node_set() + .map_err(|_| RightsEvaluationErrorV1::DecisionBinding)?; + authenticated + .verify_node_rights_decision(&signed_decision, &node_set, now_unix_seconds) + .map_err(RightsEvaluationErrorV1::from)?; + RightsProviderResponseV1::new_decision(&signed_decision).map_err(Into::into) + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum RightsEvaluationErrorV1 { + #[error("invalid node signing key")] + InvalidNodeSigningKey, + #[error("selected node does not match evaluator")] + WrongSelectedNode, + #[error("rights evidence does not match request")] + EvidenceBinding, + #[error("rights decision window is invalid")] + DecisionWindow, + #[error("rights decision does not match request")] + DecisionBinding, + #[error("rights evidence source failed")] + EvidenceSource, + #[error("protected-content contract error")] + Contract, + #[error("protected-content release binding error")] + ReleaseBinding, +} + +impl From for RightsEvaluationErrorV1 { + fn from(_: ContractError) -> Self { + Self::Contract + } +} + +impl From for RightsEvaluationErrorV1 { + fn from(_: KeyReleaseError) -> Self { + Self::ReleaseBinding + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use elastos_auth::ethereum_signed_message_hash; + use elastos_protected_content_contracts::{ + CustodyApprovedSuitesV1, CustodyCommitteeAuthorizationIdentityV1, + CustodyEnvelopeManifestV1, CustodyEnvelopeV1, CustodyEpochIssuerKeyV1, + CustodyEpochStatementV1, CustodyPoolIdentityV1, Digest32, EncryptedContentIdentityV1, + EvmContractAddressV1, EvmFunctionSelectorV1, EvmRightsMethodAbiV1, HpkeCiphertextV1, + KeyReleaseRequestV1, NodeCustodyPublicKeyV1, ProtectedContentBindingV1, + RecipientKeyAuthorizationStatementV1, RecipientKeyIdentityV1, RecipientPublicKeyBytesV1, + ReplayNonce16, RightsActionV1, RightsEvaluationEvidenceRequestV1, + RightsObservationFinalityV1, RightsPolicyBodyV1, RightsSubjectSourceV1, + RuntimeOperationIssuerKeyV1, RuntimeReleaseAuditIdV1, RuntimeReleaseOperationStatementV1, + RuntimeSessionBindingV1, ShareCoordinateV1, SignedCustodyEpochV1, + SignedRecipientKeyAuthorizationV1, SignedRuntimeReleaseOperationV1, ThresholdV1, + WalletAddress, WalletSignedRightsRequestV1, CUSTODY_HPKE_SUITE_ID_V1, + HPKE_ENCAPPED_KEY_BYTES, HPKE_SEALED_SHARE_BYTES, + }; + use elastos_protected_content_provider_contracts::{ + RightsProviderRequestV1, RightsProviderResponseStatusV1, ValidatedRightsProviderRequestV1, + }; + use k256::ecdsa::SigningKey as WalletSigningKey; + use sha3::{Digest as _, Keccak256}; + use std::{ + future::Future, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll, Waker}, + }; + + const NOW: u64 = 2_000_000_000; + + fn digest(seed: u8) -> Digest32 { + Digest32::new([seed; 32]) + } + + fn node_signing_key(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) + } + + fn node_public_key(seed: u8) -> NodePublicKey { + NodePublicKey::new(node_signing_key(seed).verifying_key().to_bytes()).unwrap() + } + + fn wallet(seed: u8) -> WalletAddress { + let key = WalletSigningKey::from_slice(&[seed; 32]).unwrap(); + let encoded = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&encoded.as_bytes()[1..]); + WalletAddress::new(digest[12..].try_into().unwrap()) + } + + fn recipient_public_key(seed: u8) -> RecipientPublicKeyBytesV1 { + let mut bytes = [0u8; 32]; + bytes[0] = seed.max(9); + RecipientPublicKeyBytesV1::new(bytes).unwrap() + } + + fn recipient_identity(seed: u8) -> RecipientKeyIdentityV1 { + recipient_public_key(seed) + .key_identity(CUSTODY_HPKE_SUITE_ID_V1) + .unwrap() + } + + fn policy_body() -> RightsPolicyBodyV1 { + RightsPolicyBodyV1::new( + "content:alpha", + RightsActionV1::View, + "view", + RightsSubjectSourceV1::WalletAddress, + 11155111, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(12), + ) + .unwrap() + } + + fn signed_custody_epoch() -> SignedCustodyEpochV1 { + let issuer_key = SigningKey::from_bytes(&[0x71; 32]); + let nodes = vec![ + elastos_protected_content_contracts::CustodyNodeIdentityV1::new( + node_public_key(1), + NodeCustodyPublicKeyV1::new([0x31; 32]).unwrap(), + ShareCoordinateV1::new(1).unwrap(), + ) + .unwrap(), + elastos_protected_content_contracts::CustodyNodeIdentityV1::new( + node_public_key(2), + NodeCustodyPublicKeyV1::new([0x32; 32]).unwrap(), + ShareCoordinateV1::new(2).unwrap(), + ) + .unwrap(), + elastos_protected_content_contracts::CustodyNodeIdentityV1::new( + node_public_key(3), + NodeCustodyPublicKeyV1::new([0x33; 32]).unwrap(), + ShareCoordinateV1::new(3).unwrap(), + ) + .unwrap(), + ]; + let statement = CustodyEpochStatementV1::new( + CustodyEpochIssuerKeyV1::new(issuer_key.verifying_key().to_bytes()).unwrap(), + CustodyApprovedSuitesV1::new( + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + ) + .unwrap(), + ThresholdV1::new(2, 3).unwrap(), + nodes, + ) + .unwrap(); + SignedCustodyEpochV1::new( + statement.clone(), + issuer_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() + } + + fn custody_envelope(seed: u8) -> CustodyEnvelopeV1 { + let epoch = signed_custody_epoch(); + let manifest = CustodyEnvelopeManifestV1::new( + EncryptedContentIdentityV1::new(digest(seed), 4096).unwrap(), + CustodyPoolIdentityV1::new(digest(seed ^ 0x34), 512).unwrap(), + epoch.epoch_identity().unwrap(), + CustodyCommitteeAuthorizationIdentityV1::new(digest(seed ^ 0x35), 512).unwrap(), + ThresholdV1::new(2, 3).unwrap(), + digest(seed ^ 0x33), + epoch.statement().nodes().to_vec(), + ) + .unwrap(); + let shares = [seed ^ 0x50, seed ^ 0x51, seed ^ 0x52] + .into_iter() + .map(|seed| { + let mut encapped_key = [0u8; HPKE_ENCAPPED_KEY_BYTES]; + encapped_key[0] = seed.max(9); + let mut ciphertext = [0u8; HPKE_SEALED_SHARE_BYTES]; + ciphertext.fill(seed); + HpkeCiphertextV1::new(encapped_key, ciphertext).unwrap() + }) + .collect(); + CustodyEnvelopeV1::new(manifest, shares).unwrap() + } + + fn binding_for_envelope(envelope: &CustodyEnvelopeV1) -> ProtectedContentBindingV1 { + let policy = policy_body(); + ProtectedContentBindingV1::new( + envelope.manifest().encrypted_content().clone(), + envelope.key_envelope_identity().unwrap(), + policy.policy_identity().unwrap(), + elastos_protected_content_contracts::ProfileIdentityV1::from_public_key_bytes( + SigningKey::from_bytes(&[0x26; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap(), + wallet(7), + RuntimeSessionBindingV1::new(digest(0x66)).unwrap(), + ) + .unwrap() + } + + fn signed_operation_for_envelope( + seed: u8, + envelope: &CustodyEnvelopeV1, + ) -> SignedRuntimeReleaseOperationV1 { + let runtime_key = SigningKey::from_bytes(&[seed; 32]); + let binding = binding_for_envelope(envelope); + let rights_request = { + let request = elastos_protected_content_contracts::RightsRequestV1::new( + binding.clone(), + RightsActionV1::View, + recipient_identity(0x30), + NOW, + NOW + 180, + ReplayNonce16::new([0x55; 16]), + ) + .unwrap(); + let key = WalletSigningKey::from_slice(&[7; 32]).unwrap(); + let (signature, recovery_id) = key + .sign_prehash_recoverable(ðereum_signed_message_hash( + &request.canonical_bytes().unwrap(), + )) + .unwrap(); + let mut signature_bytes = signature.to_bytes().to_vec(); + signature_bytes.push(recovery_id.to_byte()); + WalletSignedRightsRequestV1::new(request, signature_bytes).unwrap() + }; + let release_request = KeyReleaseRequestV1::new( + binding.clone(), + rights_request.request().request_hash().unwrap(), + RightsActionV1::View, + rights_request.request().recipient().clone(), + NOW + 1, + NOW + 50, + ReplayNonce16::new([0x66; 16]), + ) + .unwrap(); + let profile = SigningKey::from_bytes(&[0x26; 32]); + let recipient_public_key = recipient_public_key(0x30); + let authorization_statement = RecipientKeyAuthorizationStatementV1::new( + binding.clone(), + RightsActionV1::View, + recipient_public_key, + rights_request.request().recipient().clone(), + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + NOW, + NOW + 90, + ) + .unwrap(); + let authorization = SignedRecipientKeyAuthorizationV1::new( + authorization_statement.clone(), + profile + .sign(&authorization_statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(); + let policy = policy_body(); + let evidence_request = RightsEvaluationEvidenceRequestV1::new( + binding.clone(), + policy.policy_identity().unwrap(), + ) + .unwrap(); + let statement = RuntimeReleaseOperationStatementV1::new( + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + rights_request, + release_request, + recipient_public_key, + authorization, + policy, + evidence_request, + signed_custody_epoch(), + RuntimeReleaseAuditIdV1::new(digest(0x91 ^ seed)).unwrap(), + NOW + 2, + NOW + 40, + ) + .unwrap(); + SignedRuntimeReleaseOperationV1::new( + statement.clone(), + runtime_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() + } + + fn signed_operation(seed: u8) -> SignedRuntimeReleaseOperationV1 { + signed_operation_for_envelope(seed, &custody_envelope(0x11)) + } + + fn validated_request( + operation: &SignedRuntimeReleaseOperationV1, + node_seed: u8, + ) -> ValidatedRightsProviderRequestV1 { + let request = + RightsProviderRequestV1::new_evaluate(node_public_key(node_seed), operation).unwrap(); + ValidatedRightsProviderRequestV1::decode_and_validate_at( + &request.to_json_vec().unwrap(), + operation.statement().runtime_operation_issuer(), + NOW + 3, + ) + .unwrap() + } + + fn evidence_for( + request: &ValidatedRightsProviderRequestV1, + has_access: bool, + ) -> RightsEvaluationEvidenceV1 { + let authenticated = request.authenticated_runtime_release_operation(); + let policy = authenticated.statement().policy_body(); + let evidence_request = authenticated.statement().evidence_request(); + RightsEvaluationEvidenceV1::new( + authenticated.operation_hash(), + authenticated.release_request_hash(), + evidence_request.binding().clone(), + evidence_request.policy_identity().clone(), + evidence_request.binding().wallet(), + policy.chain_id(), + 100, + digest(0x88), + 112, + has_access, + NOW + 4, + NOW + 30, + ) + .unwrap() + } + + fn block_on(future: F) -> F::Output { + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + let mut future = std::pin::pin!(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::yield_now(), + } + } + } + + #[derive(Clone)] + struct StaticEvidenceSource { + evidence: RightsEvaluationEvidenceV1, + calls: Arc, + } + + impl TrustedRightsEvidenceSourceV1 for StaticEvidenceSource { + fn acquire_evidence_at( + &self, + _request: &ValidatedRightsProviderRequestV1, + _now_unix_seconds: u64, + ) -> RightsEvidenceFutureV1<'_> { + let evidence = self.evidence.clone(); + let calls = Arc::clone(&self.calls); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + Ok(evidence) + }) + } + } + + #[derive(Clone)] + struct FailingEvidenceSource { + calls: Arc, + } + + impl TrustedRightsEvidenceSourceV1 for FailingEvidenceSource { + fn acquire_evidence_at( + &self, + _request: &ValidatedRightsProviderRequestV1, + _now_unix_seconds: u64, + ) -> RightsEvidenceFutureV1<'_> { + let calls = Arc::clone(&self.calls); + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + Err(RightsEvaluationErrorV1::EvidenceSource) + }) + } + } + + fn evaluator_with_evidence( + node_seed: u8, + evidence: RightsEvaluationEvidenceV1, + ) -> ( + ProtectedContentRightsEvaluatorV1, + Arc, + ) { + let calls = Arc::new(AtomicUsize::new(0)); + ( + ProtectedContentRightsEvaluatorV1::new( + node_signing_key(node_seed), + StaticEvidenceSource { + evidence, + calls: Arc::clone(&calls), + }, + ) + .unwrap(), + calls, + ) + } + + fn assert_no_forbidden_output(value: &[u8]) { + let text = String::from_utf8_lossy(value).to_ascii_lowercase(); + for forbidden in [ + "cek", + "share", + "route", + "endpoint", + "host", + "ip", + "port", + "rpc", + "topology", + "credential", + ] { + assert!( + !text.contains(forbidden), + "forbidden output marker {forbidden} in {text}" + ); + } + } + + #[test] + fn typed_rights_evaluator_signs_exact_allow_and_deny_decisions() { + let operation = signed_operation(0x42); + let allow_request = validated_request(&operation, 1); + let (evaluator, _) = evaluator_with_evidence(1, evidence_for(&allow_request, true)); + let allow = block_on(evaluator.evaluate_at(&allow_request, NOW + 4)).unwrap(); + assert_eq!(allow.status(), RightsProviderResponseStatusV1::Decision); + let allow_decision = allow.signed_node_rights_decision().unwrap(); + assert_eq!( + allow_decision.statement().decision(), + RightsDecisionV1::Allowed + ); + assert_eq!( + allow_decision.statement().node_public_key(), + node_public_key(1) + ); + let authenticated = allow_request.authenticated_runtime_release_operation(); + let node_set = authenticated + .statement() + .custody_epoch() + .statement() + .node_set() + .unwrap(); + authenticated + .verify_node_rights_decision(&allow_decision, &node_set, NOW + 4) + .unwrap(); + + let (deny_evaluator, _) = evaluator_with_evidence(1, evidence_for(&allow_request, false)); + let deny = block_on(deny_evaluator.evaluate_at(&allow_request, NOW + 4)).unwrap(); + assert_eq!( + deny.signed_node_rights_decision() + .unwrap() + .statement() + .decision(), + RightsDecisionV1::Denied + ); + } + + #[test] + fn typed_rights_evaluator_rejects_substituted_request_evidence_and_node() { + let operation = signed_operation(0x42); + let request = validated_request(&operation, 1); + let other_operation = signed_operation_for_envelope(0x42, &custody_envelope(0x12)); + let other_request = validated_request(&other_operation, 1); + let (evaluator, _) = evaluator_with_evidence(1, evidence_for(&other_request, true)); + + assert_eq!( + block_on(evaluator.evaluate_at(&request, NOW + 4)).unwrap_err(), + RightsEvaluationErrorV1::EvidenceBinding + ); + let (wrong_node, calls) = evaluator_with_evidence(2, evidence_for(&request, true)); + assert_eq!( + block_on(wrong_node.evaluate_at(&request, NOW + 4)).unwrap_err(), + RightsEvaluationErrorV1::WrongSelectedNode + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn typed_rights_evaluator_rejects_stale_chain_evidence() { + let operation = signed_operation(0x42); + let request = validated_request(&operation, 1); + let authenticated = request.authenticated_runtime_release_operation(); + let evidence_request = authenticated.statement().evidence_request(); + let policy = authenticated.statement().policy_body(); + let stale = RightsEvaluationEvidenceV1::new( + authenticated.operation_hash(), + authenticated.release_request_hash(), + evidence_request.binding().clone(), + evidence_request.policy_identity().clone(), + evidence_request.binding().wallet(), + policy.chain_id(), + 100, + digest(0x88), + 111, + true, + NOW + 4, + NOW + 30, + ) + .unwrap(); + let (evaluator, _) = evaluator_with_evidence(1, stale); + assert_eq!( + block_on(evaluator.evaluate_at(&request, NOW + 4)).unwrap_err(), + RightsEvaluationErrorV1::EvidenceBinding + ); + } + + #[test] + fn typed_rights_evaluator_rejects_future_and_expired_evidence() { + let operation = signed_operation(0x42); + let request = validated_request(&operation, 1); + let authenticated = request.authenticated_runtime_release_operation(); + let policy = authenticated.statement().policy_body(); + let evidence_request = authenticated.statement().evidence_request(); + let future = RightsEvaluationEvidenceV1::new( + authenticated.operation_hash(), + authenticated.release_request_hash(), + evidence_request.binding().clone(), + evidence_request.policy_identity().clone(), + evidence_request.binding().wallet(), + policy.chain_id(), + 100, + digest(0x88), + 112, + true, + NOW + 10, + NOW + 30, + ) + .unwrap(); + assert_eq!( + block_on( + evaluator_with_evidence(1, future) + .0 + .evaluate_at(&request, NOW + 4) + ) + .unwrap_err(), + RightsEvaluationErrorV1::EvidenceBinding + ); + + let expired = RightsEvaluationEvidenceV1::new( + authenticated.operation_hash(), + authenticated.release_request_hash(), + evidence_request.binding().clone(), + evidence_request.policy_identity().clone(), + evidence_request.binding().wallet(), + policy.chain_id(), + 100, + digest(0x88), + 112, + true, + NOW + 4, + NOW + 6, + ) + .unwrap(); + assert_eq!( + block_on( + evaluator_with_evidence(1, expired) + .0 + .evaluate_at(&request, NOW + 7) + ) + .unwrap_err(), + RightsEvaluationErrorV1::EvidenceBinding + ); + } + + #[test] + fn async_rights_evaluator_propagates_source_failure_without_signing() { + let operation = signed_operation(0x42); + let request = validated_request(&operation, 1); + let calls = Arc::new(AtomicUsize::new(0)); + let evaluator = ProtectedContentRightsEvaluatorV1::new( + node_signing_key(1), + FailingEvidenceSource { + calls: Arc::clone(&calls), + }, + ) + .unwrap(); + + assert_eq!( + block_on(evaluator.evaluate_at(&request, NOW + 4)).unwrap_err(), + RightsEvaluationErrorV1::EvidenceSource + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn async_rights_evaluator_can_be_dropped_before_evidence_or_signing() { + let operation = signed_operation(0x42); + let request = validated_request(&operation, 1); + let (evaluator, calls) = evaluator_with_evidence(1, evidence_for(&request, true)); + + let pending_evaluation = evaluator.evaluate_at(&request, NOW + 4); + drop(pending_evaluation); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn typed_rights_evaluator_is_deterministic_for_exact_replay() { + let operation = signed_operation(0x42); + let request = validated_request(&operation, 1); + let evidence = evidence_for(&request, true); + let (evaluator, _) = evaluator_with_evidence(1, evidence); + + let first = evaluator.evaluate_at(&request, NOW + 4); + let first = block_on(first).unwrap().to_json_vec().unwrap(); + let second = evaluator.evaluate_at(&request, NOW + 4); + let second = block_on(second).unwrap().to_json_vec().unwrap(); + assert_eq!(first, second); + assert_no_forbidden_output(&first); + assert_no_forbidden_output(format!("{evaluator:?}").as_bytes()); + } + + #[test] + fn validated_request_rejects_signature_mutation_and_wrong_runtime_issuer() { + let operation = signed_operation(0x42); + let request = + RightsProviderRequestV1::new_evaluate(node_public_key(1), &operation).unwrap(); + let mut bytes = request.to_json_vec().unwrap(); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(!text.contains("observed_evidence")); + assert!(!text.contains("has_access")); + let pos = bytes + .iter() + .rposition(|byte| byte.is_ascii_digit()) + .expect("request contains signature digits"); + bytes[pos] = if bytes[pos] == b'0' { b'1' } else { b'0' }; + assert!(ValidatedRightsProviderRequestV1::decode_and_validate_at( + &bytes, + operation.statement().runtime_operation_issuer(), + NOW + 3, + ) + .is_err()); + assert!(ValidatedRightsProviderRequestV1::decode_and_validate_at( + &request.to_json_vec().unwrap(), + RuntimeOperationIssuerKeyV1::new( + SigningKey::from_bytes(&[0x24; 32]) + .verifying_key() + .to_bytes() + ) + .unwrap(), + NOW + 3, + ) + .is_err()); + } +} diff --git a/elastos/crates/elastos-server/src/provider_resource.rs b/elastos/crates/elastos-server/src/provider_resource.rs index bed0fd40e..e68626d38 100644 --- a/elastos/crates/elastos-server/src/provider_resource.rs +++ b/elastos/crates/elastos-server/src/provider_resource.rs @@ -54,11 +54,6 @@ pub fn build_capability_resource( .and_then(|value| value.as_str()) .ok_or_else(|| "chain provider request missing network".to_string())?; validate_chain_network(network)?; - if op == "has_access_by_content_id" { - return Ok(format!( - "elastos://chain/{network}/rights/has_access_by_content_id" - )); - } if op == "erc1271_is_valid_signature" { return Ok(format!("elastos://chain/{network}/proof/erc1271")); } @@ -234,7 +229,6 @@ fn chain_op_required_action(op: &str) -> Option { | "balance" | "transaction" | "receipt" - | "has_access_by_content_id" | "proof" | "erc1271_is_valid_signature" | "contract_call" @@ -844,13 +838,28 @@ mod tests { "elastos://chain/meta/networks" ); assert_eq!( + build_capability_resource("rights", "has_access_by_content_id", &serde_json::json!({})) + .unwrap(), + "elastos://rights/access/has_access_by_content_id", + "the active provisional rights-provider mapping is preserved until product cutover" + ); + assert!( + build_capability_resource( + "chain", + "protected_content_rights_evidence", + &serde_json::json!({}) + ) + .is_err(), + "protected-content rights evidence must stay Runtime-internal" + ); + assert!( build_capability_resource( "chain", "has_access_by_content_id", &serde_json::json!({"network": "esc-mainnet"}) ) - .unwrap(), - "elastos://chain/esc-mainnet/rights/has_access_by_content_id" + .is_err(), + "legacy Chain rights rail must not remain capsule-invokable" ); assert_eq!( build_capability_resource( diff --git a/state.md b/state.md index d98a1efb0..052bd3658 100644 --- a/state.md +++ b/state.md @@ -52,56 +52,46 @@ the public repository. reconstruction inside the decrypt boundary for new content. Its current reviewed behavior rejects invalid X25519 contract key bytes before HPKE use, requires exactly the bound released threshold, and checks a manifest-bound - CEK commitment after reconstruction to detect a wrong reconstructed key. The - current published protected-content stack therefore ends at `a8d0a9a6` on - `origin/feat/protected-content-key-reconstruction`. It does not integrate - Runtime/provider/Carrier - orchestration, Runtime-owned durable replay storage, full operational - custody state, recipient key-possession proof, product decrypt/render flows, - installation, or deployment. A durable local claim without a stored result - fails closed and currently requires a fresh Runtime release operation; there - is no operation-resume journal for that state. -- The local unpublished `feat/protected-content-payload-sealing` child branch - keeps staged payload sealing inside - `elastos-protected-content-custody`. That is an additive custody-extension - exception, not a new product path: keeping CEK generation, CEK commitment, - custody-envelope provisioning, and zeroization in one crypto boundary is - safer than exposing or duplicating CEK APIs across crates. The staging writer - returns canonical metadata only after ciphertext staging and custody - provisioning both succeed; callers must discard staged output after every - error. This branch is source-only and unpublished. -- The local unpublished `feat/protected-content-decrypt-output` child branch - depends on that sealing branch and keeps decrypt-output inside the same - custody boundary. It accepts the exact encrypted-content identity plus the - existing authenticated release inputs, reconstructs the CEK only inside the - custody crate, verifies the full framed ciphertext identity before any - plaintext write, authenticates every chunk before staging plaintext, and - returns only bounded plaintext metadata after full success. On any error it - returns no success metadata and callers must discard staged plaintext output. - This branch is source-only and unpublished. It does not add provider wire, - Runtime integration, viewer output protocol, installation, or deployment. -- The local unpublished `feat/protected-content-custody-pool` child branch - depends on that unpublished custody-local stack and adds a source-only - custody-pool module inside the canonical - `elastos-protected-content-contracts` crate. Validation requires the exact - signed pool, exact signed custody epoch, exact signed committee - authorization, a caller-supplied trusted policy-authority key, and a - caller-supplied expected `CustodyCommitteeAuthorizationIdentityV1`. It - enforces fixed 2-of-3 diversity across distinct operators and failure - domains and rejects topology-bearing fields. Object-bound pool/epoch/ - authorization identity commitment, Runtime/provider routing, installation, - and deployment are not implemented. This branch is source-only and - unpublished. -- The local unpublished `feat/protected-content-custody-provider` child branch - depends on that custody-pool work and remains source-only and unregistered. - It proves exact object/pool/epoch/committee binding, one selected node and one - sealed-share provisioning record, expected Runtime issuer and local-node - validation, owner-only durable node-share storage, exact duplicate/conflict/ - restart behavior, signed-rights-gated release, exact encrypted contribution - replay, bounded provider frames, and clean shutdown. It exposes no CEK, raw - share, topology, Carrier authority, route, host, IP address, port, or - credential. The old provisional `key-provider` remains the only active + CEK commitment after reconstruction to detect a wrong reconstructed key. A + durable local claim without a stored result fails closed and currently + requires a fresh Runtime release operation; there is no operation-resume + journal for that state. +- `origin/feat/protected-content-custody-provider` is the current published + source-only protected-content review line at `f7cd6c3d`. Its ancestry includes + provider protocol, authenticated payload sealing, local decrypt-output, + object-bound custody-pool policy, one-node provisioning authority, expected + Runtime issuer pinning, owner-only durable node-share storage, and the + unregistered `capsules/custody-provider` process. It proves exact + object/pool/epoch/committee binding, one selected node and one sealed-share + provisioning record, local-node validation, exact duplicate/conflict/restart + behavior, signed-rights-gated release, exact encrypted contribution replay, + bounded provider frames, redacted diagnostics, and clean shutdown. It exposes + no CEK, raw share, topology, Carrier authority, route, host, IP address, + port, path, or credential. It is not registered, installed, deployed, or + product-current. The old provisional `key-provider` remains the only active registered product key/custody path until a later atomic Runtime cutover. +- The local unpublished `feat/protected-content-wallet-rights` child branch at + `2c69d0c2` adds a dedicated Wallet operation for protected-content rights. It + signs the exact canonical `RightsRequestV1` bytes for the selected active EVM + account through the existing verified Wallet invocation context. It does not + add Runtime, Library, provider registration, Carrier, or product UI behavior. +- The local unpublished `feat/protected-content-runtime` child branch at + `b00bfeeb` adds private durable Runtime release state and typed internal + coordination over the Wallet-rights, rights-provider, and custody-provider + contract types. It persists before provider effects, records effect-started + state, treats ambiguous post-dispatch outcomes as durable nonterminal state, + replays only exact stored terminal results, and prevents caller-selected + provider or topology input. It is not wired into `elastos-server` product + routes, Library, viewer output, or the installed provisional path. +- The local unpublished `feat/protected-content-rights` child branch contains + the current full-stack source planning line. Its latest code commit before + this docs-only planning commit is `a32ae85a`. The local chain-evidence commit + `7c747253` adds typed chain-rights evidence; `a32ae85a` adds the typed rights + evaluator. Evidence acquisition is bound to the exact Runtime release + operation, verifies live chain id, uses an exact canonical block hash/number + binding, binds contract/method selector, has bounded freshness, redacts + upstream failures, and does not accept caller-supplied rights facts. It + remains source-only and unregistered. - `CustodyEnvelopeV1` is current source-only provisioning authority, not public asset metadata. Future durable custody storage must keep exactly one node-sealed share at each selected custody node. Public metadata contains no @@ -119,17 +109,20 @@ the public repository. aggregated `shares[]` metadata, capsule-owned authority, raw CEK operations, `rail_shim`/reference fallbacks, old `drm-provider` orchestration, direct topology in capsules or contracts, static authorization fallbacks, and - standalone harness as a product route. Its generated `escrow.json` is - historical dev evidence only because it aggregates wrapped shares and its - writer omits `cek_commitment_b64` while the reload path requires it. -- Current protected-content source proof covers canonical contracts, node - release, exact-threshold reconstruction inside the decrypt boundary, + standalone harness as a product route. Its generated producer-smoke + `escrow.json` is historical dev evidence only because it aggregates wrapped + shares. The producer smoke writes and reloads `cek_commitment_b64`; the older + Creator path carried the missing-commitment writer/reloader inconsistency. +- Current protected-content source proof covers canonical contracts, + authenticated payload sealing, one-node custody-provider storage and release, + Wallet rights signing, typed chain-rights evidence, typed Runtime internal + coordination, exact-threshold reconstruction inside the decrypt boundary, tamper/expiry/wrong-binding rejection, commitment checking, and CEK zeroization. PR #15 adds provider/dev-harness encryption and decryption - evidence. No accepted test yet proves + evidence. No accepted or installed test yet proves `mint -> Runtime authority/provider selection/audit -> per-node custody release -> private reconstruction/decryption -> plaintext/playback` through - the real Runtime product path. + the real Runtime product path with three custody nodes. - Released 0.6 and the published collaboration review stack retain the older provisional `elastos_common::protected_content` DTOs plus fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider`