diff --git a/CHANGELOG.md b/CHANGELOG.md index 21783e7a..c0e33edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Changes +- Delegation renewal uses owner-signed, exact-CID leases and signed bounded renewal stamps. Lease revocation immediately ends renewed access. - Encryption: v2 column format with key ids, per-purpose keys and recorded format metadata. Optional strict reads are off by default. Details: TC-2516, TC-2517. - Encryption network key-wrap v1 uses the explicit legacy column codec after the TC-2516 integration; wire bytes and default-off v2 writer flags are unchanged. Details: TC-2518. - Deploys: the workflow supports an explicit, reason-recorded rollback to an earlier released dstack image on the unchanged N0 compose. It checks image labels, release tag, ancestry, and identical migration sources while preserving the normal forward-release guard. diff --git a/Cargo.lock b/Cargo.lock index 1d36fea5..820b1fe3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9909,9 +9909,11 @@ dependencies = [ "async-std", "async-trait", "base64 0.22.1", + "blake3", "chacha20poly1305", "dashmap", "duckdb", + "ed25519-dalek", "futures", "hex", "hkdf", diff --git a/docs/runbooks/renewal-leases.md b/docs/runbooks/renewal-leases.md new file mode 100644 index 00000000..b7d6961a --- /dev/null +++ b/docs/runbooks/renewal-leases.md @@ -0,0 +1,9 @@ +# Renewal lease operations + +This runtime slice supports owner-signed lease registration, node-internal scheduled renewal, the existing revocation flow, and read-only status. There is no external renewal trigger or route: the node scheduler signs renewals with its dedicated renewer key, which is never returned to clients. External clients register owner-signed leases and read status. + +Stopping and re-enabling a lease are deferred to a later slice. Until then, the owner's only off switch is revocation, which is irreversible. + +The node persists a monotonic renewer epoch beginning at 1. A lease and its stamps are usable only while both the lease's renewer DID and epoch match the node's current identity. If a KMS key change produces a new renewer DID, old leases and their stamps stop extending grants; owners must register new leases. This is an availability consequence, not a security failure. + +A database restore can roll the persisted epoch back. An old-epoch lease could become usable again only if the old renewer key also returns, producing the matching DID and epoch. Keep the restored database and node key state consistent; do not treat a database restore alone as revocation. diff --git a/docs/specs/stop-reenable-contract-v1.md b/docs/specs/stop-reenable-contract-v1.md new file mode 100644 index 00000000..d9d3ffca --- /dev/null +++ b/docs/specs/stop-reenable-contract-v1.md @@ -0,0 +1,270 @@ +# Stop and re-enable renewal: proposed control contract v1 + +Status: DOC-ONLY PROPOSAL, revised for Bobbie323fd442 (six required changes), for delta review then Naomi freeze. Not approved wire vocabulary. Requested by Naomi3411713e. No runtime or frozen-fixture changes are authorized by this document. + +The current node runtime slice ships **renew + existing revoke/status only**. Stop/re-enable are deferred to the next slice. Existing #545/#546 Renew leases grant only `tinycloud.delegation/renew`; this proposal does not modify those bytes, CIDs, capabilities or the stamp format. `design.md` R5 is target behavior for that later slice, not a claim that stop is currently exposed. Never substitute revoke when a caller asks to stop: revoking the lease invalidates renewal-added time and falls back to the original signed expiry; revoking the original ends its access/descendants. Stop preserves admitted stamps. This proposal adds no “stop and end now” mode. + +## Recommended route, actions and target + +Recommend ONE new thin endpoint in the **existing delegation-control route family**, `POST /delegation/renewal/state`, using the strict owner-control validation template in `tinycloud-core/src/encryption_network/delete_auth.rs:301–356`: explicit audience, proofless principal, one capability, lifetime, nonce and unknown-caveat rejection. Existing `/delegation/status` supplies only route-family/exact-CID precedent; its signature/time-only verifier is NOT a sufficient auth template (it lacks audience/replay checks). This endpoint does not exist today. Reuse control authorization/replay/transaction code; do not route these owner operations through generic data `/invoke`, and do not overload `/revoke` with a stop mode. + +| Desired transition | Exact proposed ability | Exact signed resource | +|---|---|---| +| active → stopped | `tinycloud.delegation/stop-renewal` | `urn:cid:` | +| stopped → active | `tinycloud.delegation/enable-renewal` | `urn:cid:` | + +`enable` means D13 re-enable of a lease previously stopped through this contract. Registration always starts an owner-authorized renewable lease at `state=active, generation=1`; there is no registered-stopped initial mode in v1. Turning renewal on later for an originally nonrenewing grant requires explicit owner-signed lease registration while that original is live, then active/gen1. It never creates/extends a lease or changes its holder, period or abilities. The target is the **lease**, not the original, so multiple historical leases cannot be confused. New lease authorization/registration remains separate. The server resolves the lease's original from stored signed artifacts; no client-supplied original/holder/scope is trusted. + +Alternative considered: two `/invoke` action handlers on the original CID. Rejected for this slice: expands the generic invocation path and leaves lease selection ambiguous. Existing control-family route + exact lease target is the narrowest extension. Final route/action strings and the numeric caveat below are one-way contract choices for Naomi to freeze after review. + +## Lease cardinality and initial state — recommended freeze + +Require **at most ONE unrevoked, unexpired lease per (canonical original CID, renewer identity epoch)**. A stopped lease counts against this limit. Enforce at registration under the SAME original-CID advisory lock: a repeated registration of the identical lease is idempotent and never resets state/generation; a different live lease conflicts. Do not create lease B to bypass stopped lease A. This closes the multi-lease stop bypass rather than silently stopping just one of two active leases. Different historical expired/revoked leases remain auditable and cannot be re-enabled. A new separately authorized lease must still pass original-live/D15 checks after revoked/expired stamps are excluded. + +If the preceding runtime admits multiple live leases, this cardinality must be enforced/reconciled before the stop endpoint is advertised: return owner-only409 `ambiguous_renewal_lease` for an ambiguous original, never choose one silently or auto-revoke another. Existing affected rows need explicit owner resolution; this doc authorizes no migration or new user flow. Alternative for Naomi: define stop as original-wide across all leases. Recommendation is the ≤1 rule; no all-lease implicit mutation. + +Initial state is **active/gen1** only after explicit owner-authorized registration. Nonrenewing grants have no lease row. D13 re-enable mutates an existing live stopped row, never re-registers to reset its generation. + +## Owner authentication and signed request + +`Authorization` is the existing verifiable signed invocation format accepted by the node (including compact Ed25519 UCAN where applicable), with normal signature, audience, nbf/expiry and replay validation. No new signature scheme. The issuer must resolve to the **original delegation's delegator**, using canonical principal matching (`ControlAction::party_may_act` issuing-side rule). “Owner” here means that authenticated issuer, not arbitrary space owner, current holder or renewer. Under the original-CID lock ALSO require the verified issuer equals the lease’s OWN signed issuer, defending against a misregistered lease. In v1 require direct issuer authorization and `prf: []`; no delegated stop/enable management, space/network fallback, cookie-only authorization or unsigned profile metadata. + +For stop/enable: exactly one resource, one ability, and a caveat LIST of exactly ONE object with exactly ONE key (`expectedGeneration`). Check the raw signed shape before normalization: reject `[{},{}]`, `[{"expectedGeneration":1},{}]`, `[{}]`, objects with extra keys or collapsed/array-index keys (`"0"`, `"1"`). Read uses a distinct explicitly empty caveat profile below. Mutation example: + +```json +{ + "urn:cid:": { + "tinycloud.delegation/stop-renewal": [ { "expectedGeneration": 1 } ] + } +} +``` + +For enable, use the enable ability and current expected generation. `expectedGeneration` is a nonnegative integer representable exactly in JSON (≤2^53−1); reject floats, negative/overflow values and unknown caveat keys. The control parser MUST validate this signed caveat; it must not drop it as a generic unknown restriction. Lease generation cannot wrap. + +Body (strict keys and types): + +```json +{"version":1,"leaseCid":"","expectedGeneration":1} +``` + +Signed exact resource must equal `urn:cid:` + body lease CID, and signed expected generation must equal body generation. The CAS operand is ALWAYS taken from that SIGNED caveat, never the body; body generation is only an equality check. The action is derived ONLY from the single signed ability, never a body `state`, query, HTTP method override or generic resource interpretation. The body is not a separate signature envelope: all mutable selection is bound by those signed checks. Body version must be exactly1; future versions cannot be interpreted as v1. Reject URI paths, fragments, queries, wildcards, Space/Network resources, duplicate/multiple abilities and a target whose CID is not the stored canonical lease CID. This route requires canonical CID strings, not alias guessing. + +Require invocation audience = current stable node DID; node time only, fresh nonce and current invocation lifetime ceiling (the existing compact profile is ≤60s). No grace or timestamp-only replay protection. Replay identity is **(canonical authenticated invoker, nonce)** through `invoker_nonce_reservation`, NEVER invocation content_hash (re-signing/changing exp must not reuse a nonce). Insert that reservation atomically with the successful state mutation; the state-read route also reserves its nonce in its protected transaction. Repeated successful nonce is refused; recovery reads status rather than performing a second state change. Unknown/transient transaction outcome must not be labelled success. + +A Renew lease is NEVER a management proof. The renewer DID remains barred from generic `/delegate` and `/invoke`, and cannot call this owner control route either. The holder cannot stop/re-enable merely because it holds the original grant. An owner changing a setting requires a newly signed explicit request; nothing automatically re-enables after a stop. + +## Conditional mutation and D13/D18 semantics + +Use the SAME `pg_advisory_xact_lock` keyed by canonical original CID as renew/registration; SQLite uses its reserved write transaction. Begin one write transaction; acquire the canonical-original lock before authoritative reads. Never rely on in-process locks or mix a separate `FOR UPDATE` synchronization regime. + +Under that lock re-read canonical original/lease, verified original delegator, lease chain + revocations, original/ancestor current effective ends, current renewer identity epoch, lease end, current state and generation. Check original delegator AND lease signed issuer before producing any lease existence/state-specific response; the owner must still be authorized at commit. Not-found and not-owner have the SAME403 code/message, with no state/generation/leaseEnd details. No404/409 detail is returned to a non-owner. Reject revoked original/ancestor/lease, identity mismatch, an expired lease, or a lapsed original. State changes cannot restore expired or revoked authority (D15/D18). + +- **Stop:** conditional `UPDATE ... SET state='stopped', generation=generation+1 WHERE original=$O AND lease=$L AND state='active' AND generation=$expected`. Exactly one row or409. It creates NO stamp, changes NO signed artifact/end/period, and does NOT mark anything revoked. Admitted stamps continue to their previously admitted end under lease, ancestor, revocation and identity constraints. +- **Enable/re-enable:** only the original delegator can conditionally change `stopped → active`, same generation increment, while the original is effectively LIVE and the lease/chain unrevoked, lease end future, epoch current. It writes NO stamp, moves NO end, and continues the existing lease-CID-rooted stamp chain. A revoked lease never returns. A new signed lease is a separate operation; this control cannot silently create a successor or extend90d to1y. +- **Same-state requests:** return409 `renewal_state_conflict`, not a mutation or new stamp. Clients may query status to see the actual state; no hidden retry with a new generation. Nonce and CAS conflicts cannot increment generation twice. + +Lease generation is mutation/CAS history only, NOT a use-time equality predicate. Stop/re-enable increments must never invalidate old stamps. Renewer **identity epoch** is separate and remains a use-time validity check. Explicit revocation or true node identity change still invalidates affected stamps. Revocation joins are authoritative at use; even if a competing revoke commits after a successful enable, the state cannot override that revoke. + +Commit state update and invocation-replay insertion in the SAME transaction. Rollback both on failure. Audit rows use existing Status read authorization (issuer/delegatee as applicable, or space owner), not extra data access. No new reader may see another holder's lease history. A successful receipt reports only confirmed state: + +```json +{"version":1,"originalCid":"","leaseCid":"","state":"stopped","generation":2,"leaseEnd":""} +``` + +Enable returns active/generation3 for the example sequence. The owner-authorized state read defined below supplies the CAS generation (the current `/delegation/status` response DOES NOT contain it). It must distinguish stopped/active and signed lease end; no success response promises an additional renewal period. If the older node lacks this endpoint, SDK returns unsupported, with no fallback to revoke or silent local-only preference. + +## Owner-authorized lease-state read (required for CAS recovery) + +Use the SAME proposed `POST /delegation/renewal/state` endpoint, with exactly one signed `tinycloud.delegation/status` capability on `urn:cid:`, `prf: []`, and exactly one EMPTY caveat object `[{}]`. Do not accept a generation caveat/extra keys on this read. Body is exactly `{"version":1,"leaseCid":""}`. This read profile is distinct from mutation’s mandatory one-key caveat; the decoder must not infer operation from normalized empty objects. + +Same strict signature/audience/lifetime/nonce and ORIGINAL-delegator == LEASE-issuer checks apply. A generic `/delegation/status` grant or lease recipient’s existing Status rights DO NOT bypass this endpoint’s owner-only rule. No renewer/holder/delegated proof may fetch state here. In a transaction, resolve authority before returning existence/state and reserve `(invoker, nonce)`; same403 for unknown lease and wrong owner. Do not return cached generation. Authorized result: + +```json +{"version":1,"originalCid":"","leaseCid":"","state":"stopped","generation":2,"leaseEnd":""} +``` + +`state`, `generation`, `leaseEnd` are required and current. A successful stop/enable receipt contains the same fields, including leaseEnd, after its confirmed commit. Owner reads may inspect stopped/revoked/expired lease history without mutating it. Existing status/revocation APIs supply revocation/expiry reasons where needed; no additional flags or fabricated state change are introduced by this read. Owner can safely learn generation before signing a new CAS. Unknown transaction outcome → state read with a NEW nonce; never reuse successful mutation nonce or blindly retry a mutation with a guessed generation. + +## Error contract (proposed v1) + +Responses contain `{version:1,code:,message:}`; owner text is reviewed separately before implementation. Do not echo request tokens, raw node errors, URLs or signed artifacts. All refusal cases leave state/stamps unchanged; unauthenticated callers do not receive grant details. + +| HTTP | Code | Condition | +|---|---|---| +|400|`invalid_request`|Bad CID/schema/version, multiple actions/resources, unknown caveat, invalid integer or malformed nonce.| +|401|`auth_required`|Missing/bad signature, expired/not-yet-valid invocation or wrong node audience.| +|403|`unauthorized_control`|Not-found OR not-owner (same static message/no details); signer must match BOTH original delegator and signed lease issuer. Also renewer/holder/proof/wrong binding/wildcard/Space/Network refusal.| +|409|`renewal_state_conflict`|OWNER-ONLY: wrong state, stale generation, overflow or conditional update not exactly one row.| +|409|`ambiguous_renewal_lease`|OWNER-ONLY: multiple unexpired/unrevoked leases for the original/epoch; no partial stop or automatic resolution.| +|409|`invocation_replay`|Successful nonce already committed; never treat it as a new state change.| +|409|`grant_expired`|Original or any required ancestor no longer effectively live.| +|409|`lease_expired`|Lease reached its signed end; re-enable cannot extend it.| +|409|`grant_revoked` / `lease_revoked`|Original/ancestor or lease chain revoked; status-only outcome, no resurrection.| +|409|`renewer_identity_changed`|Lease belongs to another renewer identity epoch.| +|503|`renewal_state_unavailable`|Database/revocation/registry lookup/commit outcome unavailable; fail closed, do not turn it into404/403 or automatic re-request.| + +## Synthetic signed vectors (doc-only; not yet node conformance evidence) + +Use #546 fixture `bd2f95ca`'s public synthetic owner seed `11`×32, owner DID `did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S`, original CID `bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4`, lease CID `bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq`, node DID `did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7`. These are PUBLIC TEST DATA, never live keys. + +Known-answer authorizations below were computed locally with existing SDK `signCompactUcanAuthorization` and independently checked by the Ed25519 verifier. Signed bytes are ASCII `.`, existing UCAN0.10.0/EdDSA profile; no new signing format or stamp signature domain is introduced. They are proposals to freeze, not executed against a stop endpoint (none exists). All three use nbf1701000000/exp1701000060, fct[{}], no proofs; verifier clock1701000001. Fixture lease signed end1731536000, original signed end1702592000. Preload original/lease/chain as valid, epoch1, and existing first stamp from #546. Tests after original signed expiry must use effective expiry, not discard it locally. + +### V1: stop active generation1 → stopped generation2 + +Request body: + +```json +{"version":1,"leaseCid":"bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq","expectedGeneration":1} +``` + +Signed claims: + +```json +{ + "att": { + "urn:cid:bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq": { + "tinycloud.delegation/stop-renewal": [ + { + "expectedGeneration": 1 + } + ] + } + }, + "aud": "did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7", + "exp": 1701000060, + "fct": [ + {} + ], + "iss": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S#z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S", + "nbf": 1701000000, + "nnc": "urn:uuid:00000000-0000-4000-8000-000000000001", + "prf": [] +} +``` + +Authorization (one literal line): + +```text +eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9zdG9wLXJlbmV3YWwiOlt7ImV4cGVjdGVkR2VuZXJhdGlvbiI6MX1dfX0sImF1ZCI6ImRpZDprZXk6ejZNa3BYdmVVeWpwQ0RlNlA4M3JOWXFBWm1vVGM5cW9wSjZHQnVSUVJvdHozekY3IiwiZXhwIjoxNzAxMDAwMDYwLCJmY3QiOlt7fV0sImlzcyI6ImRpZDprZXk6ejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTI3o2TWt0VUx1ZFR0QXNBaFJlZ1lQaVo2NjMxUlYzdml2MTJxZDRHUUY4ejF4QjIyUyIsIm5iZiI6MTcwMTAwMDAwMCwibm5jIjoidXJuOnV1aWQ6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwicHJmIjpbXX0.a7ep_-r1c0emPJJO0p1zQBu9ZGDAbTL1VU_P3GVINGQlwXwIQDkcFm1MWYHGUKGk6qPwGxVEu75FQlGdF5VYDQ +``` + +Detached signature hex: `6bb7a9ffeaf57347a63c924ed29d73401bbd6460c06d32f5554fcfdc6548346425c17c0840391c166d4c5981c650a1a4eaa3f01b1544bbbe4542519d1795580d`. Signing-input SHA-256: `314cf79709ef6bc8f75da4186e0bc69660e4492c848db949294f0dc99ce5e3ce`. + +### V2: enable stopped generation2 → active generation3 + +Request body: + +```json +{"version":1,"leaseCid":"bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq","expectedGeneration":2} +``` + +Signed claims: + +```json +{ + "att": { + "urn:cid:bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq": { + "tinycloud.delegation/enable-renewal": [ + { + "expectedGeneration": 2 + } + ] + } + }, + "aud": "did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7", + "exp": 1701000060, + "fct": [ + {} + ], + "iss": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S#z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S", + "nbf": 1701000000, + "nnc": "urn:uuid:00000000-0000-4000-8000-000000000002", + "prf": [] +} +``` + +Authorization (one literal line): + +```text +eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9lbmFibGUtcmVuZXdhbCI6W3siZXhwZWN0ZWRHZW5lcmF0aW9uIjoyfV19fSwiYXVkIjoiZGlkOmtleTp6Nk1rcFh2ZVV5anBDRGU2UDgzck5ZcUFabW9UYzlxb3BKNkdCdVJRUm90ejN6RjciLCJleHAiOjE3MDEwMDAwNjAsImZjdCI6W3t9XSwiaXNzIjoiZGlkOmtleTp6Nk1rdFVMdWRUdEFzQWhSZWdZUGlaNjYzMVJWM3ZpdjEycWQ0R1FGOHoxeEIyMlMjejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTIiwibmJmIjoxNzAxMDAwMDAwLCJubmMiOiJ1cm46dXVpZDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIiLCJwcmYiOltdfQ.RJwsIMaEuh9yMRyKvMhHj06BFANolxxan0a1qLEHAcLEXXTnxtXicyEiNgTp-AAS6m8Q4sX_kTN4rEDl2k1-Dg +``` + +Detached signature hex: `449c2c20c684ba1f72311c8abcc8478f4e81140368971c5a9f46b5a8b10701c2c45d74e7c6d5e27321223604e9f80012ea6f10e2c5ff913378ac40e5da4d7e0e`. Signing-input SHA-256: `acb42ead8923fd435747920f806c7a5cb0b9b81b17c0a4c055a968cbb9654be7`. + +### V3-read: learn the current CAS generation + +The existing node `/delegation/status` shape is insufficient. The third signed sample below binds an owner-only state read to the SAME actual lease CID. It uses an independent nonce; afterV1 it must return state=stopped, generation2 and signed leaseEnd, without changing stamps/state. + +Body: + +```json +{"version":1,"leaseCid":"bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq"} +``` + +Claims: + +```json +{ + "att": { + "urn:cid:bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq": { + "tinycloud.delegation/status": [ + {} + ] + } + }, + "aud": "did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7", + "exp": 1701000060, + "fct": [ + {} + ], + "iss": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S#z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S", + "nbf": 1701000000, + "nnc": "urn:uuid:00000000-0000-4000-8000-000000000003", + "prf": [] +} +``` + +Authorization: + +```text +eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9zdGF0dXMiOlt7fV19fSwiYXVkIjoiZGlkOmtleTp6Nk1rcFh2ZVV5anBDRGU2UDgzck5ZcUFabW9UYzlxb3BKNkdCdVJRUm90ejN6RjciLCJleHAiOjE3MDEwMDAwNjAsImZjdCI6W3t9XSwiaXNzIjoiZGlkOmtleTp6Nk1rdFVMdWRUdEFzQWhSZWdZUGlaNjYzMVJWM3ZpdjEycWQ0R1FGOHoxeEIyMlMjejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTIiwibmJmIjoxNzAxMDAwMDAwLCJubmMiOiJ1cm46dXVpZDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDMiLCJwcmYiOltdfQ.hzLmQd4IObMUOwNXAfKTWnxhfeZ1KM2-ekzW7tyU6ruoPdyftqhjjz3QDblzgjzv8-LD-OkXJmXJI0ceH-b8AQ +``` + +Signature hex: `8732e641de0839b3143b035701f2935a7c617de67528cdbe7a4cd6eedc94eabba83ddc9fb6a8638f3dd00db973823ceff3e2c3f8e9172665c923471e1fe6fc01`; signing-input SHA-256: `d55df0c5258a6d3a96c112d635faf03db8675864bf991b5d16efa4ab5dc8caa3`. + +### Required conformance and race vectors + +Each mutation below is independently re-signed where stated, so authorization checks—not just bad-signature detection—must fail. Keep positive controls. New fixture/test code is for the FOLLOWING slice only, after freeze. + +| Vector | Input/state | Expected result | +|---|---|---| +|V1|Above owner stop, active/gen1, live/unrevoked/current epoch|200 stopped/gen2; no stamp or end changed.| +|V2|Above owner enable afterV1, stopped/gen2, same live lease|200 active/gen3; no stamp/end changed; same stamp chain.| +|V3|Use #546's gen1 stamp after stop/gen2 and after enable/gen3|Still valid until admitted end, unless original/lease/ancestor revoked or epoch changed.| +|V4|Body generation2 with V1 signed generation1 unchanged|403; no mutation/replay insertion.| +|V5|Fresh owner signature/gen1 after successfulV1 state/gen2|409 generation/state conflict; no second increment.| +|V6|V1 exact token reused after success|409 replay; no second increment.| +|V7|Holder seed22×32 signs otherwise valid stop/enable|403; holder is not original delegator.| +|V8|Derived renewer key signs same; or uses Renew lease as proof|403; renewal right is not management right.| +|V9|Owner signs stop resource naming ORIGINAL CID instead of LEASE CID, body unchanged|403 target mismatch.| +|V10|Re-sign wildcard, Space/Network target, extra resource/ability/caveat, or add proof|400/403 according to error table; never broaden via space-owner branch.| +|V11|Clock equals original's EFFECTIVE end, even lease still live|409 grant_expired; enable never revives.| +|V12|Stopped lease but revoked original, ancestor or lease chain|409 revoked; no re-enable/stamp. A fresh lease requires separate authorization.| +|V13|Clock equals lease end; or true node identity changes|409 lease_expired/renewer_identity_changed; no extension.| +|V14|Signature bit flips, audience changed, expired invocation, nbf in future|401; no state changes.| +|V15|Corrupt/unavailable registry or unknown commit outcome|503; no success claim, no automatic revoke fallback.| +|V16|Two processes: same generation, two valid stop nonces|Exactly one commit/gen increment; loser409.| +|V17|Two processes: stop ∥ renew (both use same canonical-original lock)|Renew-before-stop admitted stamp survives; stop-before-renew rejects issuance. No new stamp after winning stop.| +|V18|Enable ∥ revoke original/lease/ancestor|Even if enable receipt wins first, authoritative use-time join rejects all revoked access. Never treat enable as undoing revoke.| +|V19|Stop → enable ∥ a renew that READ STATE BEFORE acquiring the lock (renew signs NO generation)|Renew must re-read lease state/generation after the shared lock. A stopped winner rejects issue; active current state permits only re-evaluated issuance. Existing admitted old stamp remains valid.| +|V20|Restart client after stop/enable, cached expiry claims access|Re-fetch node status/effective expiry; cache is display hint only.| + +Additional required vectors: owner-only read returns exact current `{state,generation,leaseEnd}`; unknown lease and known foreign lease return identical403 shape/no state; a deliberately misregistered lease whose issuer differs from the original rejects both read/mutation; duplicate live lease registration conflicts including while first is stopped; re-registering same lease cannot reset state/generation; unknown caveat-array shape/multiple entries/floats reject; same invoker+nonce with newly signed exp/body still409; read nonce cannot be reused as mutation; initial registered state active/gen1 and no registered-stopped mode. + +Pin real server success/refusal and multi-process outcomes in implementation tests, not only signed-request fixtures. Verify frozen #545/#546 fixtures remain byte-identical. D13 opt-in-after-stop is explicit owner authorization on a still-live unrevoked lease; no hidden settings-page flow or new approval prompt is imposed by this protocol. + +## Review/freeze boundaries + +Bobbie: verify direct owner authority vs proof delegation, signed generation/body binding, lease-target choice, nonce/atomic mutation, use-time lease revocation join, stop≠revoke, re-enable races, existing signature-suite support and error classification. Naomi: freeze action strings, route/resource, caveat/receipt AND owner-state-read schemas, ≤1-live-lease cardinality, initial active/gen1 and signed vectors after that review. No generic `ControlAction::Revoke/Status` permission is widened; those routes keep their behavior. No published docs/UI wording or production operation is authorized by this internal proposal. + +Primary local references: node d63d4c44 `tinycloud-core/src/encryption_network/delete_auth.rs:301–356` strict owner-control validator template (borrow checks, not key-admin-specific facts); `tinycloud-node-server/src/routes/mod.rs:630` route-family/exact-CID precedent ONLY, not sufficient authentication; `tinycloud-core/src/models/revocation.rs:375-392` ControlAction/party rules and `:480-521` direct-CID vs broader authorization; canonical `design.md` R1–R7; merged expiry spec D13/D15/D18; frozen real lease/stamp #546 bd2f95ca. Actual R5 runtime code is explicitly deferred. diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index 27dc75b0..65a77b4d 100644 --- a/test/m1-realdata-e2e/Cargo.lock +++ b/test/m1-realdata-e2e/Cargo.lock @@ -7979,8 +7979,10 @@ dependencies = [ "aes-gcm", "async-trait", "base64 0.22.1", + "blake3", "chacha20poly1305", "dashmap", + "ed25519-dalek 2.2.0", "futures", "hex", "hkdf", diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 7ed5c71f..ed2ad1d6 100644 --- a/tinycloud-core/Cargo.toml +++ b/tinycloud-core/Cargo.toml @@ -33,6 +33,8 @@ async-std = [ [dependencies] dashmap = "5.5" +blake3 = "1" +ed25519-dalek = { version = "2", default-features = false, features = ["std"] } async-trait.workspace = true sea-orm = { version = "1.1", default-features = false, features = ["macros", "with-time", "with-json", "postgres-array"] } sea-orm-migration = { version = "1.1", default-features = false } diff --git a/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index c0b04f78..be1f648a 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -211,10 +211,10 @@ async fn load_closure( /// Each branch projects the same columns, with a distinct slot for each /// underlying SQL type. In particular, JSON and text never share a UNION /// slot, and the legacy model decoders still parse every field in Rust. -fn snapshot_branch(kind: i32, fields: [SimpleExpr; 13]) -> SelectStatement { +fn snapshot_branch(kind: i32, fields: [SimpleExpr; N]) -> SelectStatement { let mut query = Query::select(); query.expr_as(Expr::val(kind), Alias::new("kind")); - for (name, expr) in [ + let names = [ "node", "parent", "delegator", @@ -228,11 +228,69 @@ fn snapshot_branch(kind: i32, fields: [SimpleExpr; 13]) -> SelectStatement { "resource", "ability", "caveats", - ] - .into_iter() - .zip(fields) - { - query.expr_as(expr, Alias::new(name)); + "renewal_original", + "lease_cid", + "lease_state", + "lease_generation", + "renewer_did", + "renewer_epoch", + "lease_expiry", + "lease_original_expiry", + "lease_period", + "audience_key", + "stamp_cid", + "previous_stamp_cid", + "stamp_epoch", + "stamp_end", + "signed_bytes", + "signature", + "lease_revoked", + "stamp_generation", + ]; + for (name, expr) in names.iter().zip(fields) { + query.expr_as(expr, Alias::new(*name)); + } + if N == 13 { + let null = || Expr::cust("NULL"); + let extra: [SimpleExpr; 18] = if kind == 4 { + [ + Expr::col(( + Alias::new("renewal_lease"), + Alias::new("original_canonical"), + )) + .into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("lease_cid"))).into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("state"))).into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("generation"))).into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("renewer_did"))).into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("renewer_epoch"))).into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("lease_expiry"))).into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("original_expiry"))).into(), + Expr::col((Alias::new("renewal_lease"), Alias::new("period_seconds"))).into(), + Expr::col(( + Alias::new("renewal_lease"), + Alias::new("audience_public_key"), + )) + .into(), + Expr::col((Alias::new("renewal_stamp"), Alias::new("cid"))).into(), + Expr::col(( + Alias::new("renewal_stamp"), + Alias::new("previous_stamp_cid"), + )) + .into(), + Expr::col((Alias::new("renewal_stamp"), Alias::new("renewer_epoch"))).into(), + Expr::col((Alias::new("renewal_stamp"), Alias::new("new_end"))).into(), + Expr::col((Alias::new("renewal_stamp"), Alias::new("signed_bytes"))).into(), + Expr::col((Alias::new("renewal_stamp"), Alias::new("signature"))).into(), + Expr::cust("FALSE"), + Expr::col((Alias::new("renewal_stamp"), Alias::new("generation"))).into(), + ] + } else { + std::array::from_fn(|_| null()) + }; + for (name, expr) in names.iter().skip(13).zip(extra) { + query.expr_as(expr, Alias::new(*name)); + } } query } @@ -397,6 +455,8 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { .from(delegation::Entity) .from(parent_delegations::Entity) .from(abilities::Entity) + .from(Alias::new("renewal_lease")) + .from(Alias::new("renewal_stamp")) .and_where(Expr::val(1).eq(0)); let mut edge_rows = snapshot_branch( 0, @@ -502,7 +562,8 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { JoinType::InnerJoin, delegation_cid_alias::Entity, source.clone(), - Expr::col((source.clone(), delegation_cid_alias::Column::Id)).equals((lookup_name, id)), + Expr::col((source.clone(), delegation_cid_alias::Column::Id)) + .equals((lookup_name.clone(), id.clone())), ) .join_as( JoinType::InnerJoin, @@ -519,6 +580,81 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { ) .and_where(within_budget()); query.union(UnionType::All, aliased_revocations); + let lease = Alias::new("renewal_lease"); + let stamp = Alias::new("renewal_stamp"); + let original_alias = Alias::new("renewal_original_alias"); + let renewal_revoked = Expr::cust(format!( + "EXISTS (WITH RECURSIVE lease_chain(id, depth) AS (SELECT renewal_lease.lease_cid, 0 UNION ALL SELECT p.parent, c.depth + 1 FROM parent_delegation p JOIN lease_chain c ON p.child = c.id WHERE c.depth < {MAX_CHAIN_TRAVERSAL_NODES}) SELECT 1 FROM lease_chain c JOIN revocation r ON r.revoked = c.id) OR EXISTS (WITH RECURSIVE lease_chain(id, depth) AS (SELECT renewal_lease.lease_cid, 0 UNION ALL SELECT p.parent, c.depth + 1 FROM parent_delegation p JOIN lease_chain c ON p.child = c.id WHERE c.depth < {MAX_CHAIN_TRAVERSAL_NODES}) SELECT 1 FROM lease_chain c JOIN delegation_cid_alias a ON a.id = c.id JOIN delegation_cid_alias other ON other.canonical = a.canonical JOIN revocation r ON r.revoked = other.id) OR EXISTS (WITH RECURSIVE lease_chain(id, depth) AS (SELECT renewal_lease.lease_cid, 0 UNION ALL SELECT p.parent, c.depth + 1 FROM parent_delegation p JOIN lease_chain c ON p.child = c.id WHERE c.depth < {MAX_CHAIN_TRAVERSAL_NODES}) SELECT 1 FROM lease_chain c JOIN parent_delegation p ON p.child = c.id WHERE c.depth = {MAX_CHAIN_TRAVERSAL_NODES})" + )); + let mut renewal_rows = snapshot_branch( + 5, + [ + node(lookup_name.clone()), + null(), + null(), + null(), + null(), + null(), + null(), + null(), + null(), + null(), + null(), + null(), + null(), + Expr::col((lease.clone(), Alias::new("original_canonical"))).into(), + Expr::col((lease.clone(), Alias::new("lease_cid"))).into(), + Expr::col((lease.clone(), Alias::new("state"))).into(), + Expr::col((lease.clone(), Alias::new("generation"))).into(), + Expr::col((lease.clone(), Alias::new("renewer_did"))).into(), + Expr::col((lease.clone(), Alias::new("renewer_epoch"))).into(), + Expr::col((lease.clone(), Alias::new("lease_expiry"))).into(), + Expr::col((lease.clone(), Alias::new("original_expiry"))).into(), + Expr::col((lease.clone(), Alias::new("period_seconds"))).into(), + Expr::col((lease.clone(), Alias::new("audience_public_key"))).into(), + Expr::col((stamp.clone(), Alias::new("cid"))).into(), + Expr::col((stamp.clone(), Alias::new("previous_stamp_cid"))).into(), + Expr::col((stamp.clone(), Alias::new("renewer_epoch"))).into(), + Expr::col((stamp.clone(), Alias::new("new_end"))).into(), + Expr::col((stamp.clone(), Alias::new("signed_bytes"))).into(), + Expr::col((stamp.clone(), Alias::new("signature"))).into(), + renewal_revoked, + Expr::col((stamp.clone(), Alias::new("generation"))).into(), + ], + ); + renewal_rows + .from(lease.clone()) + .join( + JoinType::InnerJoin, + stamp.clone(), + Expr::col((stamp.clone(), Alias::new("original_canonical"))) + .equals((lease.clone(), Alias::new("original_canonical"))) + .and( + Expr::col((stamp.clone(), Alias::new("lease_cid"))) + .equals((lease.clone(), Alias::new("lease_cid"))), + ), + ) + .join_as( + JoinType::InnerJoin, + delegation_cid_alias::Entity, + original_alias.clone(), + Expr::col(( + original_alias.clone(), + delegation_cid_alias::Column::Canonical, + )) + .equals((lease.clone(), Alias::new("original_canonical"))), + ) + .join( + JoinType::InnerJoin, + lookup_name.clone(), + Expr::col((original_alias, delegation_cid_alias::Column::Id)) + .equals((lookup_name.clone(), id.clone())), + ) + .and_where(within_budget()) + .and_where(Expr::cust( + "renewal_lease.lease_cid = renewal_stamp.lease_cid", + )); + query.union(UnionType::All, renewal_rows); query.with( WithClause::new() .recursive(true) @@ -544,12 +680,136 @@ fn sort_abilities(abilities: &mut HashMap>) { }); } } -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone)] +struct RenewalCandidate { + node: Hash, + original: String, + state: String, + lease: String, + stamp: String, + previous: String, + generation: u64, + end: i64, + signed_bytes: Vec, + signature: Vec, + did: String, + epoch: u64, + audience: [u8; 32], + now: i64, + period: i64, + lease_expiry: i64, + original_expiry: i64, +} +fn cap_effective_end(signed_end: i64, renewed_end: Option, ancestor_ends: &[i64]) -> i64 { + let own_end = renewed_end.map_or(signed_end, |end| signed_end.max(end)); + ancestor_ends + .iter() + .copied() + .min() + .map_or(own_end, |cap| own_end.min(cap)) +} +fn resolve_effective_end( + id: Hash, + parents: &HashMap>, + delegations: &HashMap, + candidates: &HashMap>, + revoked: &HashSet, + resolved: &mut HashMap, + visiting: &mut HashSet, +) -> Option { + if let Some(end) = resolved.get(&id) { + return Some(*end); + } + if revoked.contains(&id) { + visiting.remove(&id); + return None; + } + let result = (|| { + let signed_end = delegations + .get(&id)? + .expiry + .map_or(i64::MAX, |expiry| expiry.unix_timestamp()); + let mut ancestor_ends = Vec::new(); + for parent in parents.get(&id).into_iter().flatten() { + ancestor_ends.push(resolve_effective_end( + *parent, + parents, + delegations, + candidates, + revoked, + resolved, + visiting, + )?); + } + let mut renewed_end = None; + if let Some(rows) = candidates.get(&id) { + let mut expected_previous = rows.first()?.lease.clone(); + let mut used = HashSet::new(); + loop { + let matching = rows + .iter() + .enumerate() + .filter(|(_, candidate)| candidate.previous == expected_previous) + .collect::>(); + if matching.is_empty() { + break; + } + if matching.len() != 1 { + return None; + } + let (index, candidate) = matching[0]; + let latest_allowed = candidate.now.checked_add(candidate.period)?; + let stamp_verifier_now = candidate.end.checked_sub(1)?; + if candidate.original_expiry != signed_end + || candidate.end > latest_allowed + || used.contains(&index) + || crate::renewal::verify_snapshot_stamp( + crate::renewal::SnapshotStampVerification { + signed_bytes: &candidate.signed_bytes, + signature_bytes: &candidate.signature, + expected_stamp_cid: &candidate.stamp, + expected_original_cid: &candidate.original, + expected_lease_cid: &candidate.lease, + generation: candidate.generation, + previous_stamp_cid: &candidate.previous, + new_end: candidate.end, + node_did: &candidate.did, + renewer_epoch: candidate.epoch, + audience_public_key: &candidate.audience, + verifier_now: stamp_verifier_now, + period: candidate.period, + lease_expiry: candidate.lease_expiry, + ancestor_ends: &ancestor_ends, + }, + ) + .is_err() + { + return None; + } + used.insert(index); + expected_previous = candidate.stamp.clone(); + renewed_end = + Some(renewed_end.map_or(candidate.end, |end: i64| end.max(candidate.end))); + } + if used.len() != rows.len() { + return None; + } + } + Some(cap_effective_end(signed_end, renewed_end, &ancestor_ends)) + })(); + visiting.remove(&id); + if let Some(end) = result { + resolved.insert(id, end); + } + result +} +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AuthGraphSnapshot { parents: HashMap>, delegations: HashMap, abilities: HashMap>, revoked: HashSet, + renewal_effective_ends: HashMap, } impl AuthGraphSnapshot { @@ -558,9 +818,32 @@ impl AuthGraphSnapshot { pub(crate) async fn load( db: &C, roots: &[Hash], + ) -> Result { + Self::load_inner(db, roots, None).await + } + + pub(crate) async fn load_with_renewal_identity( + db: &C, + roots: &[Hash], + stable_node_did: &str, + renewer_did: &str, + epoch: u64, + verifier_now: i64, + ) -> Result { + Self::load_inner( + db, + roots, + Some((stable_node_did, renewer_did, epoch, verifier_now)), + ) + .await + } + + async fn load_inner( + db: &C, + roots: &[Hash], + identity: Option<(&str, &str, u64, i64)>, ) -> Result { let mut nodes = roots.to_vec(); - nodes.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); nodes.dedup(); if nodes.len() > MAX_CHAIN_TRAVERSAL_NODES { return Err(ChainTraversalError::LimitExceeded); @@ -571,6 +854,7 @@ impl AuthGraphSnapshot { delegations: HashMap::new(), abilities: HashMap::new(), revoked: HashSet::new(), + renewal_effective_ends: HashMap::new(), }); } @@ -581,6 +865,7 @@ impl AuthGraphSnapshot { let mut delegations = HashMap::new(); let mut abilities: HashMap> = HashMap::new(); let mut revoked = HashSet::new(); + let mut renewal_candidates = Vec::new(); let mut edge_count = 0; for row in rows { let node: Hash = row.try_get("", "node")?; @@ -622,6 +907,63 @@ impl AuthGraphSnapshot { 3 => { revoked.insert(node); } + 5 => { + let Some((stable_node_did, renewer_did, epoch, verifier_now)) = identity else { + continue; + }; + let state: String = row.try_get("", "lease_state")?; + let lease_expiry: time::OffsetDateTime = row.try_get("", "lease_expiry")?; + if state != "active" + || lease_expiry.unix_timestamp() <= verifier_now + || row.try_get::("", "lease_revoked")? + || row.try_get::("", "renewer_did")? != renewer_did + { + continue; + } + let renewer_epoch: i64 = row.try_get("", "renewer_epoch")?; + let stamp_epoch: i64 = row.try_get("", "stamp_epoch")?; + let stamp_generation: i64 = row.try_get("", "stamp_generation")?; + let period: i64 = row.try_get("", "lease_period")?; + if renewer_epoch as u64 != epoch + || stamp_epoch as u64 != epoch + || stamp_generation < 0 + || period <= 0 + { + continue; + } + let cid = |name: &str| -> Result { + Ok(Hash::try_from(row.try_get::>("", name)?) + .map_err(DbErr::from)? + .to_cid(0x55) + .to_string()) + }; + let audience: Vec = row.try_get("", "audience_key")?; + let Ok(audience): Result<[u8; 32], _> = audience.try_into() else { + continue; + }; + let end: time::OffsetDateTime = row.try_get("", "stamp_end")?; + let original_expiry: time::OffsetDateTime = + row.try_get("", "lease_original_expiry")?; + renewal_candidates.push(RenewalCandidate { + node, + state, + original: cid("renewal_original")?, + lease: cid("lease_cid")?, + stamp: cid("stamp_cid")?, + previous: cid("previous_stamp_cid")?, + generation: stamp_generation as u64, + end: end.unix_timestamp(), + signed_bytes: row.try_get("", "signed_bytes")?, + signature: row.try_get("", "signature")?, + did: stable_node_did.to_owned(), + epoch, + audience, + now: verifier_now, + period, + lease_expiry: lease_expiry.unix_timestamp(), + original_expiry: original_expiry.unix_timestamp(), + }); + } _ => return Err(DbErr::Type("unknown authorization graph row".into()).into()), } } @@ -644,13 +986,49 @@ impl AuthGraphSnapshot { } } + let mut grouped: HashMap>> = HashMap::new(); + for candidate in renewal_candidates { + grouped + .entry(candidate.node) + .or_default() + .entry(candidate.lease.clone()) + .or_default() + .push(candidate); + } + let mut candidate_map: HashMap> = HashMap::new(); + for (node, leases) in grouped { + let active: Vec<_> = leases + .values() + .filter(|rows| rows[0].state == "active") + .collect(); + if active.len() == 1 { + candidate_map.insert(node, active[0].clone()); + } + } + let mut renewal_effective_ends = HashMap::new(); + let mut resolving = HashSet::new(); + for id in nodes.iter().copied() { + let _ = resolve_effective_end( + id, + &parents, + &delegations, + &candidate_map, + &revoked, + &mut renewal_effective_ends, + &mut resolving, + ); + } Ok(Self { parents, delegations, abilities, revoked, + renewal_effective_ends, }) } + pub(crate) fn effective_expiry(&self, id: &Hash) -> Option { + self.renewal_effective_ends.get(id).copied() + } pub(crate) fn delegation(&self, id: &Hash) -> Option<&delegation::Model> { self.delegations.get(id) @@ -753,6 +1131,11 @@ impl AuthGraphSnapshot { #[cfg(test)] mod tests { + #[test] + fn renewal_extension_can_exceed_signed_expiry_but_not_ancestor_end() { + assert_eq!(cap_effective_end(100, Some(200), &[250]), 200); + assert_eq!(cap_effective_end(100, Some(200), &[150]), 150); + } use super::*; use crate::hash::hash; use crate::migrations::Migrator; @@ -760,7 +1143,7 @@ mod tests { use crate::types::{Caveats, Facts}; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ConnectOptions, Database, DatabaseConnection, - DbBackend, EntityTrait, QueryFilter, Statement, + DbBackend, EntityTrait, QueryFilter, Statement, Value, }; use sea_orm_migration::MigratorTrait; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -786,6 +1169,277 @@ mod tests { (db, counter) } + fn db_datetime(unix: i64) -> time::PrimitiveDateTime { + let utc = OffsetDateTime::from_unix_timestamp(unix) + .unwrap() + .to_offset(time::UtcOffset::UTC); + utc.date().with_time(utc.time()) + } + + #[allow(clippy::too_many_arguments)] + async fn insert_snapshot_renewal( + db: &DatabaseConnection, + original: Hash, + lease: Hash, + ancestor: Hash, + original_end: i64, + ancestor_end: i64, + now: i64, + broken_predecessor: bool, + ) -> (String, String, u64) { + let backend = DbBackend::Sqlite; + let (renewer_did, epoch, audience) = crate::renewal::renewal_identity_from_seed([7; 32]); + let stable_node_did = "did:key:stable-node".to_owned(); + let signer = + crate::renewal::RenewalSigner::from_seed([7; 32], stable_node_did.clone(), epoch); + for (id, expiry) in [ + (original, original_end), + (lease, now + 5_000), + (ancestor, ancestor_end), + ] { + db.execute(Statement::from_sql_and_values( + backend, + "INSERT INTO delegation (id, delegator, delegatee, expiry, issued_at, not_before, facts, serialization, serialization_format) VALUES ($1,'did:key:actor','did:key:actor',$2,NULL,NULL,NULL,$3,0)", + [ + Value::Bytes(Some(Box::new(Vec::::from(id)))), + Value::TimeDateTime(Some(Box::new(db_datetime(expiry)))), + Value::Bytes(Some(Box::new(Vec::::from(id)))), + ], + )).await.unwrap(); + db.execute(Statement::from_sql_and_values( + backend, + "INSERT INTO delegation_cid_alias (id, canonical, encrypted) VALUES ($1,$1,FALSE)", + [Value::Bytes(Some(Box::new(Vec::::from(id))))], + )) + .await + .unwrap(); + } + for child in [original, lease] { + db.execute(Statement::from_sql_and_values( + backend, + "INSERT INTO parent_delegation (parent, child) VALUES ($1,$2)", + [ + Value::Bytes(Some(Box::new(Vec::::from(ancestor)))), + Value::Bytes(Some(Box::new(Vec::::from(child)))), + ], + )) + .await + .unwrap(); + } + db.execute(Statement::from_sql_and_values( + backend, + "INSERT INTO renewal_lease (original_canonical, lease_cid, state, generation, grantor, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key) VALUES ($1,$2,'active',1,'did:key:actor',$3,$4,$5,$6,5000,$7)", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::String(Some(Box::new(renewer_did.clone()))), + Value::BigInt(Some(epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(now + 5_000)))), + Value::TimeDateTime(Some(Box::new(db_datetime(original_end)))), + Value::Bytes(Some(Box::new(audience.to_vec()))), + ], + )).await.unwrap(); + let original_cid = original.to_cid(0x55).to_string(); + let lease_cid = lease.to_cid(0x55).to_string(); + let mut previous = if broken_predecessor { + hash(b"orphan-predecessor") + } else { + lease + }; + for end in [now + 1_100, now + 1_200] { + let end_text = OffsetDateTime::from_unix_timestamp(end) + .unwrap() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + let (bytes, signature, _) = signer + .sign_stamp( + &original_cid, + &lease_cid, + 1, + &previous.to_cid(0x55).to_string(), + &end_text, + ) + .unwrap(); + let stamp_hash = hash(&bytes); + db.execute(Statement::from_sql_and_values( + backend, + "INSERT INTO renewal_stamp (cid, original_canonical, lease_cid, generation, previous_stamp_cid, renewer_epoch, new_end, signed_bytes, signature, replay_hash) VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9)", + [ + Value::Bytes(Some(Box::new(Vec::::from(stamp_hash)))), + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::Bytes(Some(Box::new(Vec::::from(previous)))), + Value::BigInt(Some(epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(end)))), + Value::Bytes(Some(Box::new(bytes.clone()))), + Value::Bytes(Some(Box::new(signature))), + Value::Bytes(Some(Box::new(hash(&bytes).as_ref().to_vec()))), + ], + )).await.unwrap(); + previous = stamp_hash; + } + (stable_node_did, renewer_did, epoch) + } + #[tokio::test] + async fn snapshot_verifies_stamp_history_caps_expiry_and_fails_closed() { + let (db, counter) = counted_database().await; + let original = hash(b"snapshot-renewal-original"); + let lease = hash(b"snapshot-renewal-lease"); + let ancestor = hash(b"snapshot-renewal-ancestor"); + let (stable_did, renewer_did, epoch) = + insert_snapshot_renewal(&db, original, lease, ancestor, 2_000, 2_250, 1_000, false) + .await; + assert_ne!(stable_did, renewer_did); + let before = counter.load(Ordering::SeqCst); + let snapshot = AuthGraphSnapshot::load_with_renewal_identity( + &db, + &[original], + &stable_did, + &renewer_did, + epoch, + 2_150, + ) + .await + .unwrap(); + // The predecessor ends at 2_100, before verifier time 2_150. + assert_eq!(counter.load(Ordering::SeqCst) - before, 1); + assert_eq!(snapshot.effective_expiry(&original), Some(2_200)); + assert!(snapshot.effective_expiry(&original).unwrap() > 2_000); + assert!(snapshot.effective_expiry(&original).unwrap() <= 2_250); + + let wrong_epoch = AuthGraphSnapshot::load_with_renewal_identity( + &db, + &[original], + &stable_did, + &renewer_did, + epoch.wrapping_add(1), + 2_150, + ) + .await + .unwrap(); + assert_eq!(wrong_epoch.effective_expiry(&original), Some(2_000)); + + revocation::ActiveModel { + id: Set(hash(b"snapshot-renewal-revoke")), + revoker: Set("did:key:actor".to_owned()), + revoked: Set(lease), + serialization: Set(vec![1]), + revoked_at: Set(None), + } + .insert(&db) + .await + .unwrap(); + let revoked = AuthGraphSnapshot::load_with_renewal_identity( + &db, + &[original], + &stable_did, + &renewer_did, + epoch, + 2_150, + ) + .await + .unwrap(); + assert_eq!(revoked.effective_expiry(&original), Some(2_000)); + + let (orphan_db, _) = counted_database().await; + let original = hash(b"snapshot-renewal-orphan-original"); + let lease = hash(b"snapshot-renewal-orphan-lease"); + let ancestor = hash(b"snapshot-renewal-orphan-ancestor"); + let (stable_did, renewer_did, epoch) = insert_snapshot_renewal( + &orphan_db, original, lease, ancestor, 2_000, 2_250, 1_000, true, + ) + .await; + let orphan = AuthGraphSnapshot::load_with_renewal_identity( + &orphan_db, + &[original], + &stable_did, + &renewer_did, + epoch, + 2_150, + ) + .await + .unwrap(); + assert_eq!(orphan.effective_expiry(&original), None); + } + + #[tokio::test] + async fn invocation_accepts_active_renewal_after_original_signed_expiry() { + let (db, _) = counted_database().await; + let original = hash(b"snapshot-invocation-renewed-original"); + let lease = hash(b"snapshot-invocation-renewed-lease"); + let ancestor = hash(b"snapshot-invocation-renewed-ancestor"); + let holder = "did:key:renewed-holder"; + let (stable_did, renewer_did, epoch) = + insert_snapshot_renewal(&db, original, lease, ancestor, 2_000, 2_250, 1_000, false) + .await; + actor::ActiveModel { + id: Set(holder.to_owned()), + } + .insert(&db) + .await + .unwrap(); + db.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "UPDATE delegation SET delegatee = $1 WHERE id = $2", + [ + Value::String(Some(Box::new(holder.to_owned()))), + Value::Bytes(Some(Box::new(Vec::::from(original)))), + ], + )) + .await + .unwrap(); + for id in [original, ancestor] { + db.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO ability (delegation, resource, ability, caveats) VALUES ($1,$2,$3,$4)", + [ + Value::Bytes(Some(Box::new(Vec::::from(id)))), + Value::String(Some(Box::new("tinycloud:key:actor:files/kv".to_owned()))), + Value::String(Some(Box::new("tinycloud.kv/get".to_owned()))), + Value::Json(Some(Box::new(serde_json::json!({})))), + ], + )) + .await + .unwrap(); + } + let capability = crate::util::Capability { + resource: "tinycloud:key:actor:files/kv/doc".parse().unwrap(), + ability: "tinycloud.kv/get".to_owned().try_into().unwrap(), + caveats: Caveats::default(), + }; + let signed_only_graph = AuthGraphSnapshot::load(&db, &[original]).await.unwrap(); + assert!(crate::models::invocation::authorize_holder_with_graph( + &db, + holder, + &[capability.clone()], + &[original.to_cid(0x55)], + OffsetDateTime::from_unix_timestamp(2_150).unwrap(), + &signed_only_graph, + ) + .await + .is_err()); + + let graph = AuthGraphSnapshot::load_with_renewal_identity( + &db, + &[original], + &stable_did, + &renewer_did, + epoch, + 2_150, + ) + .await + .unwrap(); + crate::models::invocation::authorize_holder_with_graph( + &db, + holder, + &[capability], + &[original.to_cid(0x55)], + OffsetDateTime::from_unix_timestamp(2_150).unwrap(), + &graph, + ) + .await + .unwrap(); + } // Frozen pre-batch implementation used only to compare complete snapshots. async fn load_sequential( db: &C, @@ -798,6 +1452,7 @@ mod tests { delegations: HashMap::new(), abilities: HashMap::new(), revoked: HashSet::new(), + renewal_effective_ends: HashMap::new(), }); } let delegations = delegation::Entity::find() @@ -830,17 +1485,24 @@ mod tests { delegations, abilities: ability_rows, revoked, + renewal_effective_ends: HashMap::new(), }) } + fn assert_snapshot_matches_legacy(actual: &AuthGraphSnapshot, expected: &AuthGraphSnapshot) { + let mut actual = (*actual).clone(); + actual.renewal_effective_ends.clear(); + assert_eq!(actual, *expected); + } #[tokio::test] async fn snapshot_load_is_one_round_trip_and_matches_sequential() { let (db, counter) = counted_database().await; + let ids = insert_chain(&db, "one-trip", 2).await; let expected = load_sequential(&db, &[ids[0]]).await.unwrap(); let before = counter.load(Ordering::SeqCst); let actual = AuthGraphSnapshot::load(&db, &[ids[0]]).await.unwrap(); - assert_eq!(actual, expected); + assert_snapshot_matches_legacy(&actual, &expected); assert_eq!(counter.load(Ordering::SeqCst) - before, 1); } @@ -922,7 +1584,7 @@ mod tests { ] { let before = load_sequential(db, &roots).await.unwrap(); let after = AuthGraphSnapshot::load(db, &roots).await.unwrap(); - assert_eq!(after, before, "roots {roots:?}"); + assert_snapshot_matches_legacy(&after, &before); } assert_eq!( AuthGraphSnapshot::load(db, &[]).await.unwrap(), @@ -1044,7 +1706,7 @@ mod tests { snapshot.delegation(ids.last().unwrap()).is_some(), "the 64th node must not be silently truncated" ); - assert_eq!(snapshot, load_sequential(db, &[ids[0]]).await.unwrap()); + assert_snapshot_matches_legacy(&snapshot, &load_sequential(db, &[ids[0]]).await.unwrap()); } async fn insert_alias(db: &DatabaseConnection, id: Hash, canonical: Hash) { @@ -1093,7 +1755,7 @@ mod tests { snapshot.first_revoked_ancestor(&ids[0]), Some(ids[1].to_cid(0x55).to_string()) ); - assert_eq!(snapshot, load_sequential(db, &[ids[0]]).await.unwrap()); + assert_snapshot_matches_legacy(&snapshot, &load_sequential(db, &[ids[0]]).await.unwrap()); let cited = AuthGraphSnapshot::load(db, &[ids[1]]).await.unwrap(); assert!(cited.is_revoked(&ids[1])); assert!(!cited.is_revoked(&ids[2])); @@ -1186,7 +1848,7 @@ mod tests { let context = format!("{backend} seed {seed} roots {roots:?}"); let batched = AuthGraphSnapshot::load(db, &roots).await.unwrap(); let sequential = load_sequential(db, &roots).await.unwrap(); - assert_eq!(batched, sequential, "{context}"); + assert_snapshot_matches_legacy(&batched, &sequential); for root in &roots { let revoked = revocation::is_revoked(db, root).await.unwrap(); assert_eq!(batched.is_revoked(root), revoked, "{context}"); diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 032ffd2c..a7d2453d 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -266,6 +266,7 @@ pub struct SpaceDatabase { candidate_conn: RecoveringConnection, storage: B, secrets: S, + renewal_identity: Option, encryption: Option, sql_sizes: SqlSizes, delegated_sql_caveats: bool, @@ -356,6 +357,8 @@ pub enum TxError { InvalidInvocation(#[from] invocation::InvocationError), #[error(transparent)] InvalidRevocation(#[from] revocation::RevocationError), + #[error(transparent)] + Renewal(#[from] crate::renewal::RenewalStoreError), #[error("Epoch Hashing Err: {0}")] EpochHashingErr(#[from] HashError), #[error(transparent)] @@ -582,7 +585,10 @@ where { /// Open an unencrypted delegation database and complete historical CID /// indexing before accepting requests. - pub async fn new(conn: C, storage: B, secrets: K) -> Result { + pub async fn new(conn: C, storage: B, secrets: K) -> Result + where + K: Secrets, + { Self::new_with_encryption(conn, storage, secrets, None).await } @@ -594,7 +600,10 @@ where storage: B, secrets: K, encryption: Option, - ) -> Result { + ) -> Result + where + K: Secrets, + { let pool: RecoveringConnection = conn.clone().into(); // sea-orm-migration's schema manager accepts only the raw sea-orm // connection. Migrations run once at boot, before any request. @@ -606,6 +615,20 @@ where } delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; tx.commit().await?; + let renewal_identity = match (secrets.renewal_seed(), secrets.renewal_node_did()) { + (Some(seed), Some(stable_node_did)) => { + let (renewer_did, epoch, _) = + crate::renewal::initialize_renewal_identity(&pool, seed) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + Some(crate::renewal::RenewalNodeIdentity { + stable_node_did, + renewer_did, + epoch, + }) + } + _ => None, + }; let writer_lock = (pool.get_database_backend() == sea_orm::DatabaseBackend::Sqlite) .then(|| Arc::new(tokio::sync::Mutex::new(()))); let read_audit = ReadAuditPipeline::start(pool.clone(), writer_lock.clone()); @@ -614,6 +637,7 @@ where conn, storage, secrets, + renewal_identity, encryption, sql_sizes: SqlSizes::default(), delegated_sql_caveats: false, @@ -690,6 +714,50 @@ where pub async fn stage_key(&self, space_id: &SpaceId) -> Result { self.secrets.stage_keypair(space_id).await.map(get_did_key) } + /// Returns the authorization graph's current effective end for a delegation. + pub async fn delegation_effective_expiry( + &self, + target: Hash, + verifier_now: i64, + ) -> Result, crate::renewal::RenewalStoreError> + where + C: ConnectionTrait, + { + let effective_root = crate::models::revocation::target_for_cid(&self.conn, target) + .await? + .map_or(target, |delegation| delegation.id); + let roots = [effective_root]; + let snapshot = match self.renewal_identity.as_ref() { + Some(identity) => crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &self.conn, + &roots, + &identity.stable_node_did, + &identity.renewer_did, + identity.epoch, + verifier_now, + ) + .await + .map_err(|error| match error { + crate::models::revocation::ChainTraversalError::Db(error) => { + crate::renewal::RenewalStoreError::Db(error) + } + crate::models::revocation::ChainTraversalError::LimitExceeded => { + crate::renewal::RenewalStoreError::InvalidInput + } + })?, + _ => crate::auth_graph::AuthGraphSnapshot::load(&self.conn, &roots) + .await + .map_err(|error| match error { + crate::models::revocation::ChainTraversalError::Db(error) => { + crate::renewal::RenewalStoreError::Db(error) + } + crate::models::revocation::ChainTraversalError::LimitExceeded => { + crate::renewal::RenewalStoreError::InvalidInput + } + })?, + }; + Ok(snapshot.effective_expiry(&effective_root)) + } } impl SpaceDatabase { @@ -705,9 +773,27 @@ impl SpaceDatabase { pub fn kv_sync_cursor_key(&self) -> [u8; 32] { self.secrets.derive_key(b"tinycloud/kv/sync-cursor/v1") } + /// Issue a renewal stamp using this database's configured node secret. + pub async fn issue_renewal_stamp( + &self, + request: crate::renewal::StampIssue, + ) -> Result + where + C: ConnectionTrait + TransactionTrait, + { + let identity = self + .renewal_identity + .as_ref() + .ok_or(crate::renewal::RenewalStoreError::IdentityEpoch)?; + crate::renewal::issue_stamp(&self.conn, request, &self.secrets, identity).await + } } - impl SpaceDatabase { + /// Persisted renewal identity captured during database startup. + pub fn renewal_identity(&self) -> Option<&crate::renewal::RenewalNodeIdentity> { + self.renewal_identity.as_ref() + } + /// Exposes the node's capability database for projections that are /// committed alongside ordinary delegation/revocation writes. pub fn connection(&self) -> &C { @@ -878,7 +964,20 @@ where &self, invocation: &crate::util::InvocationInfo, now: OffsetDateTime, - ) -> Result<(), crate::models::invocation::Error> { + ) -> Result<(), crate::models::invocation::Error> + where + K: Secrets, + { + if self.secrets.renewal_seed().is_some_and(|seed| { + crate::renewal::renewal_identity_from_seed(seed).0 == invocation.invoker + }) { + return Err( + crate::models::invocation::InvocationError::UnauthorizedInvoker( + invocation.invoker.clone(), + ) + .into(), + ); + } invocation::verify_and_authorize_with_setting( &self.conn, invocation, @@ -896,10 +995,24 @@ where &self, invocation: &crate::AdmittedInvocation, now: OffsetDateTime, - ) -> Result<(), crate::models::invocation::Error> { + ) -> Result<(), crate::models::invocation::Error> + where + K: Secrets, + { + let invocation_info = &invocation.invocation().0; + if self.secrets.renewal_seed().is_some_and(|seed| { + crate::renewal::renewal_identity_from_seed(seed).0 == invocation_info.invoker + }) { + return Err( + crate::models::invocation::InvocationError::UnauthorizedInvoker( + invocation_info.invoker.clone(), + ) + .into(), + ); + } invocation::authorize_admitted( &self.conn, - &invocation.invocation().0, + invocation_info, now, None, self.delegated_sql_caveats, @@ -1866,6 +1979,7 @@ where &tx, &self.storage, &self.secrets, + self.renewal_identity.as_ref(), events, self.encryption.as_ref(), None, @@ -2066,6 +2180,14 @@ where } pub async fn delegate(&self, delegation: Delegation) -> Result> { + if self.secrets.renewal_seed().is_some_and(|seed| { + crate::renewal::renewal_identity_from_seed(seed).0 == delegation.0.delegator + }) { + return Err(TxError::InvalidDelegation( + delegation::DelegationError::UnauthorizedDelegator(delegation.0.delegator.clone()), + )); + } + let renewal_lease = crate::renewal::lease_original_from_signed(&delegation)?.is_some(); let parent_hashes: Vec = delegation .0 .parents @@ -2094,14 +2216,14 @@ where .one(&self.conn) .await { - Ok(Some(_)) => { + Ok(Some(_)) if !renewal_lease => { // The content-addressed row authenticates these exact signed bytes. for space in delegation.0.spaces() { crate::request_telemetry::observe_space(space); } return Ok(already_registered_result(retained_hash)); } - Ok(None) => {} + Ok(Some(_)) | Ok(None) => {} Err(e) => return Err(TxError::Db(e)), } match self @@ -2134,6 +2256,19 @@ where &self, delegations: Vec, ) -> Result> { + if let Some(seed) = self.secrets.renewal_seed() { + let renewer_did = crate::renewal::renewal_identity_from_seed(seed).0; + if let Some(delegation) = delegations + .iter() + .find(|item| item.0.delegator == renewer_did) + { + return Err(TxError::InvalidDelegation( + delegation::DelegationError::UnauthorizedDelegator( + delegation.0.delegator.clone(), + ), + )); + } + } self.transact( delegations .into_iter() @@ -2151,10 +2286,24 @@ where tx: &DatabaseTransaction, delegations: Vec, ) -> Result> { + if let Some(seed) = self.secrets.renewal_seed() { + let renewer_did = crate::renewal::renewal_identity_from_seed(seed).0; + if let Some(delegation) = delegations + .iter() + .find(|item| item.0.delegator == renewer_did) + { + return Err(TxError::InvalidDelegation( + delegation::DelegationError::UnauthorizedDelegator( + delegation.0.delegator.clone(), + ), + )); + } + } transact( tx, &self.storage, &self.secrets, + self.renewal_identity.as_ref(), delegations .into_iter() .map(|delegation| Event::Delegation(Box::new(delegation))) @@ -2188,7 +2337,10 @@ where target: Hash, invoker: &str, proofs: &[tinycloud_auth::authorization::Cid], - ) -> Result, TxError> { + ) -> Result, TxError> + where + K: Secrets, + { if proofs.len() > 1 { return Ok(None); } @@ -2256,12 +2408,15 @@ where } let now = OffsetDateTime::now_utc(); - if delegation - .expiry - .map(|expiry| now >= expiry) - .unwrap_or(false) - { - return Ok(Some(DelegationStatus::Expired)); + let effective_expiry = self + .delegation_effective_expiry(target, now.unix_timestamp()) + .await?; + match effective_expiry { + Some(end) if now.unix_timestamp() >= end => { + return Ok(Some(DelegationStatus::Expired)); + } + Some(_) => {} + None => return Ok(Some(DelegationStatus::Unavailable)), } if delegation .not_before @@ -2366,6 +2521,14 @@ where S: ImmutableStaging, S::Writable: 'static + Unpin, { + if self.secrets.renewal_seed().is_some_and(|seed| { + crate::renewal::renewal_identity_from_seed(seed).0 == invocation.0.invoker + }) { + return Err(TxError::InvalidInvocation( + invocation::InvocationError::UnauthorizedInvoker(invocation.0.invoker), + ) + .into()); + } // TC-732: a `kv/sync` invocation must be exactly one capability on a // KV resource (the root resource is the whole-space feed); anything // else is rejected before any work. @@ -2764,7 +2927,21 @@ where // recorded as a failure; it is disarmed to `ok` right before commit. let tx_body_timer = crate::telemetry::StageTimer::start(crate::telemetry::InvocationStage::DbTxBody); - let auth_graph = match crate::auth_graph::AuthGraphSnapshot::load(&tx, &roots).await { + let auth_graph_result = match self.renewal_identity.as_ref() { + Some(identity) => { + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &tx, + &roots, + &identity.stable_node_did, + &identity.renewer_did, + identity.epoch, + now.unix_timestamp(), + ) + .await + } + None => crate::auth_graph::AuthGraphSnapshot::load(&tx, &roots).await, + }; + let auth_graph = match auth_graph_result { Ok(graph) => graph, Err(error) => { crate::telemetry::observe_stage( @@ -2841,6 +3018,7 @@ where &tx, &self.storage, &self.secrets, + self.renewal_identity.as_ref(), vec![event], self.encryption.as_ref(), Some(&auth_graph), @@ -3145,18 +3323,26 @@ where .as_ref() .and_then(|sync| sync.retention_grant), ); - Some( - crate::auth_graph::AuthGraphSnapshot::load(&self.conn, &roots) + let graph_result = match self.renewal_identity.as_ref() { + Some(identity) => { + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &self.conn, + &roots, + &identity.stable_node_did, + &identity.renewer_did, + identity.epoch, + now.unix_timestamp(), + ) .await - .map_err(|error| match error { - revocation::ChainTraversalError::Db(error) => { - TxError::::Db(error) - } - revocation::ChainTraversalError::LimitExceeded => { - TxError::ChainTraversalLimitExceeded - } - })?, - ) + } + None => crate::auth_graph::AuthGraphSnapshot::load(&self.conn, &roots).await, + }; + Some(graph_result.map_err(|error| match error { + revocation::ChainTraversalError::Db(error) => TxError::::Db(error), + revocation::ChainTraversalError::LimitExceeded => { + TxError::ChainTraversalLimitExceeded + } + })?) } None => None, }; @@ -3717,6 +3903,8 @@ pub enum InvocationOutcome { EncryptionDecrypt(crate::encryption_network::DecryptResponseBody), /// `tinycloud.space/info`: the space's storage usage, rendered by the node. SpaceInfo(serde_json::Value), + /// Renewal stamp response returned by the normal invocation response path. + RenewalStamp(serde_json::Value), } #[derive(Debug)] @@ -3891,6 +4079,7 @@ pub(crate) async fn transact( db: &C, store_setup: &S, secrets: &K, + node_identity: Option<&crate::renewal::RenewalNodeIdentity>, events: Vec, encryption: Option<&ColumnEncryption>, auth_graph: Option<&crate::auth_graph::AuthGraphSnapshot>, @@ -3913,6 +4102,25 @@ pub(crate) async fn transact( let event_spaces = event_spaces(db, &event_hashes).await?; crate::kv_sync::lock_space_sequences_not_held(db, event_spaces.keys(), sequence_locks_held) .await?; + let renewal_seed = secrets.renewal_seed(); + let renewal_identity = node_identity.map(|identity| { + ( + &identity.stable_node_did, + &identity.renewer_did, + identity.epoch, + ) + }); + let mut renewal_lease_events = Vec::new(); + for (_, event) in &event_hashes { + if let Event::Delegation(lease) = event { + if crate::renewal::lease_original_from_signed(lease)?.is_some() { + if renewal_identity.is_none() { + return Err(crate::renewal::RenewalStoreError::LeaseUnavailable.into()); + } + renewal_lease_events.push(lease.as_ref()); + } + } + } // ── Atomic delegation registration ────────────────────────────────────── // Register all delegations inside this transaction before any epoch rows @@ -3921,12 +4129,37 @@ pub(crate) async fn transact( let mut registered: HashMap = HashMap::new(); for (hash, event) in &event_hashes { if let Event::Delegation(d) = event { - let reg = - delegation::process_with(db, d, encryption, delegated_sql_caveats, named_keys) - .await?; + let reg = delegation::process_with( + db, + d, + encryption, + delegated_sql_caveats, + named_keys, + renewal_identity + .as_ref() + .map(|(stable_node_did, renewer_did, epoch)| { + (stable_node_did.as_str(), renewer_did.as_str(), *epoch) + }), + ) + .await?; registered.insert(*hash, reg); } } + if let (Some(seed), Some((stable_node_did, _, renewer_epoch))) = + (renewal_seed, renewal_identity.as_ref()) + { + for lease in renewal_lease_events { + crate::renewal::register_lease( + db, + lease, + seed, + *renewer_epoch, + stable_node_did, + encryption, + ) + .await?; + } + } // Exclude Existing delegations from epoch construction: they were already // registered in a prior committed transaction and must not create new @@ -5748,6 +5981,36 @@ mod test { async fn basic() { let _db = get_db().await.unwrap(); } + #[tokio::test] + async fn renewal_identity_is_rejected_by_generic_authorization() { + let secret = StaticSecret::new([0u8; 32].to_vec()).unwrap(); + let db = SpaceDatabase::new( + Database::connect(ConnectOptions::new("sqlite::memory:".to_string())) + .await + .unwrap(), + MemoryStore::default(), + secret.clone(), + ) + .await + .unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../tinycloud-node-server/tests/fixtures/renewal-lease-v1.json" + )) + .unwrap(); + let token = tinycloud_auth::authorization::TinyCloudInvocation::decode( + fixture["originalToken"].as_str().unwrap(), + ) + .unwrap(); + let mut invocation = crate::util::InvocationInfo::try_from(token).unwrap(); + invocation.invoker = secret.renewal_did(); + assert!(matches!( + db.authorize_invocation(&invocation, OffsetDateTime::now_utc()) + .await, + Err(invocation::Error::InvalidInvocation( + invocation::InvocationError::UnauthorizedInvoker(_) + )) + )); + } /// A file-backed WAL pool with the given busy timeout and size; the first /// pool on a file also runs the migrations. diff --git a/tinycloud-core/src/keys.rs b/tinycloud-core/src/keys.rs index cde4a1d3..492d4215 100644 --- a/tinycloud-core/src/keys.rs +++ b/tinycloud-core/src/keys.rs @@ -39,6 +39,16 @@ pub trait Secrets { async fn get_peer_id(&self, space: &SpaceId) -> Result { Ok(self.get_pubkey(space).await?.to_peer_id()) } + /// Optional dedicated renewal signing seed. Implementations that do not + /// expose a node identity default to no renewal capability. + fn renewal_seed(&self) -> Option<[u8; 32]> { + None + } + /// Stable node identity embedded in renewal stamps. Separate from the + /// dedicated renewal signing identity returned by `renewal_seed`. + fn renewal_node_did(&self) -> Option { + None + } } #[async_trait] @@ -89,6 +99,17 @@ impl StaticSecret { let secret = SecretKey::try_from_bytes(derived).expect("32 bytes"); EdKP::from(secret).into() } + /// Dedicated signing identity for renewal stamps; intentionally distinct + /// from the node's storage-generation and node identity key contexts. + pub fn renewal_keypair(&self) -> Keypair { + let derived = self.derive_key(b"tinycloud/node/renewer/v1"); + let secret = SecretKey::try_from_bytes(derived).expect("32 bytes"); + EdKP::from(secret).into() + } + + pub fn renewal_did(&self) -> String { + public_key_to_did_key(self.renewal_keypair().public()) + } /// Derive the public half of the node's Share invitation signing key. pub fn share_invitation_public_key(&self) -> [u8; 32] { @@ -114,6 +135,12 @@ impl Secrets for StaticSecret { async fn save_keypair(&self, _space: &SpaceId) -> Result<(), Self::Error> { Ok(()) } + fn renewal_seed(&self) -> Option<[u8; 32]> { + Some(self.derive_key(b"tinycloud/node/renewer/v1")) + } + fn renewal_node_did(&self) -> Option { + Some(self.node_did()) + } } #[async_trait] @@ -125,3 +152,17 @@ impl SecretsSetup for StaticSecret { Ok(self.clone()) } } +#[cfg(test)] +mod renewal_identity_tests { + use super::{Secrets, StaticSecret}; + + #[test] + fn stable_node_identity_is_distinct_from_renewal_identity() { + let secret = StaticSecret::new(vec![7; 32]).unwrap(); + let stable_node_did = secret.renewal_node_did().unwrap(); + let renewer_did = secret.renewal_did(); + + assert_eq!(stable_node_did, secret.node_did()); + assert_ne!(stable_node_did, renewer_did); + } +} diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index 7f006a15..e2becab3 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod policy_authority; pub mod policy_capability; mod read_audit; pub mod relationships; +pub mod renewal; pub mod request_cancel; pub mod request_telemetry; pub mod share_email; diff --git a/tinycloud-core/src/migrations/m20261111_000000_delegation_renewals.rs b/tinycloud-core/src/migrations/m20261111_000000_delegation_renewals.rs new file mode 100644 index 00000000..8e0a2bc3 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261111_000000_delegation_renewals.rs @@ -0,0 +1,180 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum RenewalLease { + Table, + OriginalCanonical, + LeaseCid, + State, + Generation, + Grantor, + RenewerDid, + RenewerEpoch, + LeaseExpiry, + OriginalExpiry, + AudiencePublicKey, + PeriodSeconds, +} + +#[derive(DeriveIden)] +enum RenewalStamp { + Table, + Cid, + OriginalCanonical, + LeaseCid, + Generation, + PreviousStampCid, + RenewerEpoch, + NewEnd, + SignedBytes, + Signature, + ReplayHash, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(RenewalLease::Table) + .if_not_exists() + .col( + ColumnDef::new(RenewalLease::OriginalCanonical) + .binary() + .not_null(), + ) + .col(ColumnDef::new(RenewalLease::LeaseCid).binary().not_null()) + .col(ColumnDef::new(RenewalLease::State).string().not_null()) + .col( + ColumnDef::new(RenewalLease::Generation) + .big_integer() + .not_null(), + ) + .col(ColumnDef::new(RenewalLease::Grantor).string().not_null()) + .col(ColumnDef::new(RenewalLease::RenewerDid).string().not_null()) + .col( + ColumnDef::new(RenewalLease::RenewerEpoch) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(RenewalLease::LeaseExpiry) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(RenewalLease::OriginalExpiry) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(RenewalLease::PeriodSeconds) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(RenewalLease::AudiencePublicKey) + .binary() + .not_null(), + ) + .primary_key( + Index::create() + .col(RenewalLease::OriginalCanonical) + .col(RenewalLease::LeaseCid), + ) + .index( + Index::create() + .unique() + .name("uq_renewal_lease_cid") + .col(RenewalLease::LeaseCid), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(RenewalStamp::Table) + .if_not_exists() + .col(ColumnDef::new(RenewalStamp::Cid).binary().primary_key()) + .col( + ColumnDef::new(RenewalStamp::OriginalCanonical) + .binary() + .not_null(), + ) + .col(ColumnDef::new(RenewalStamp::LeaseCid).binary().not_null()) + .col( + ColumnDef::new(RenewalStamp::Generation) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(RenewalStamp::PreviousStampCid) + .binary() + .not_null(), + ) + .col( + ColumnDef::new(RenewalStamp::RenewerEpoch) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(RenewalStamp::NewEnd) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(RenewalStamp::SignedBytes) + .binary() + .not_null(), + ) + .col(ColumnDef::new(RenewalStamp::Signature).binary().not_null()) + .col(ColumnDef::new(RenewalStamp::ReplayHash).binary().not_null()) + .index( + Index::create() + .unique() + .name("uq_renewal_stamp_original_previous") + .col(RenewalStamp::OriginalCanonical) + .col(RenewalStamp::PreviousStampCid), + ) + .foreign_key( + ForeignKey::create() + .from(RenewalStamp::Table, RenewalStamp::LeaseCid) + .to(RenewalLease::Table, RenewalLease::LeaseCid), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_renewal_stamp_lease_previous") + .table(RenewalStamp::Table) + .col(RenewalStamp::LeaseCid) + .col(RenewalStamp::PreviousStampCid) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_renewal_stamp_lease_previous") + .table(RenewalStamp::Table) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(RenewalStamp::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(RenewalLease::Table).to_owned()) + .await + } +} diff --git a/tinycloud-core/src/migrations/m20261112_000000_renewal_identity.rs b/tinycloud-core/src/migrations/m20261112_000000_renewal_identity.rs new file mode 100644 index 00000000..578784c7 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261112_000000_renewal_identity.rs @@ -0,0 +1,44 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum RenewalIdentity { + Table, + Id, + RenewerDid, + Epoch, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(RenewalIdentity::Table) + .if_not_exists() + .col( + ColumnDef::new(RenewalIdentity::Id) + .integer() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(RenewalIdentity::RenewerDid).string().null()) + .col( + ColumnDef::new(RenewalIdentity::Epoch) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(RenewalIdentity::Table).to_owned()) + .await + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index a3c1c49b..01ecaa49 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -34,6 +34,8 @@ pub mod m20261022_000000_encryption_network_key; pub mod m20261023_010000_kv_orphan_candidates; pub mod m20261026_000000_kv_write_space_value_index; pub mod m20261109_000000_column_encryption_format; +pub mod m20261111_000000_delegation_renewals; +pub mod m20261112_000000_renewal_identity; pub struct Migrator; @@ -76,6 +78,8 @@ impl MigratorTrait for Migrator { Box::new(m20261023_010000_kv_orphan_candidates::Migration), Box::new(m20261026_000000_kv_write_space_value_index::Migration), Box::new(m20261109_000000_column_encryption_format::Migration), + Box::new(m20261111_000000_delegation_renewals::Migration), + Box::new(m20261112_000000_renewal_identity::Migration), ] } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 4a6da7ff..ff1a5c51 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -10,7 +10,7 @@ use sea_orm::ActiveValue::Set; use sea_orm::{ entity::prelude::*, sea_query::{Alias, CommonTableExpression, Expr, OnConflict, Query, WithClause}, - ConnectionTrait, DbBackend, QueryTrait, + ConnectionTrait, DbBackend, QueryTrait, Statement, }; use std::collections::BTreeMap; use time::OffsetDateTime; @@ -222,7 +222,7 @@ pub(crate) async fn process( delegation: &Delegation, encryption: Option<&ColumnEncryption>, ) -> Result { - process_with(db, delegation, encryption, false, true).await + process_with(db, delegation, encryption, false, true, None).await } /// As `process`; with `delegated_sql_caveats` a SQL capability may carry the @@ -234,12 +234,20 @@ pub(crate) async fn process_with( encryption: Option<&ColumnEncryption>, delegated_sql_caveats: bool, named_keys: bool, + renewal_identity: Option<(&str, &str, u64)>, ) -> Result { verify(&delegation.0.delegation).await?; for space in delegation.0.spaces() { crate::request_telemetry::observe_space(space); } - validate_with(db, &delegation.0, delegated_sql_caveats, named_keys).await?; + validate_with( + db, + &delegation.0, + delegated_sql_caveats, + named_keys, + renewal_identity, + ) + .await?; save(db, delegation.0.clone(), delegation.1.clone(), encryption).await } @@ -406,7 +414,7 @@ async fn validate( db: &C, delegation: &util::DelegationInfo, ) -> Result<(), Error> { - validate_with(db, delegation, false, true).await + validate_with(db, delegation, false, true, None).await } async fn validate_with( @@ -414,7 +422,26 @@ async fn validate_with( delegation: &util::DelegationInfo, delegated_sql_caveats: bool, named_keys: bool, + renewal_identity: Option<(&str, &str, u64)>, ) -> Result<(), Error> { + if renewal_identity.is_some_and(|(_, renewer_did, _)| delegation.delegator == renewer_did) { + return Err(DelegationError::UnauthorizedDelegator(delegation.delegator.clone()).into()); + } + for parent in &delegation.parents { + let parent_id = Hash::from(*parent); + let is_renewal_lease = db + .query_one(Statement::from_sql_and_values( + db.get_database_backend(), + "SELECT 1 AS lease FROM renewal_lease WHERE lease_cid = $1 LIMIT 1", + [sea_orm::sea_query::Value::Bytes(Some(Box::new( + parent_id.as_ref().to_vec(), + )))], + )) + .await?; + if is_renewal_lease.is_some() { + return Err(DelegationError::MissingParents.into()); + } + } // Without named keys, a capability on a key or key prefix is refused for // every delegation, before the root-authority filter, so a delegation // issued by the key's owner is covered too. @@ -484,10 +511,43 @@ async fn validate_with( // Resolve in the signed proof order. A set-based query is not // sufficient here: database row order must never become the // authority for the ordered policy/enforcer conjunction. + // Load one graph snapshot for all signed proofs, retaining proof order below. + let parent_ids: Vec = + delegation.parents.iter().copied().map(Hash::from).collect(); + let verifier_now = OffsetDateTime::now_utc().unix_timestamp(); + let loaded_graph; + let graph = if let Some((stable_node_did, renewer_did, epoch)) = renewal_identity { + loaded_graph = crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + db, + &parent_ids, + stable_node_did, + renewer_did, + epoch, + verifier_now, + ) + .await + .map_err(|error| match error { + crate::auth_graph::ChainTraversalError::Db(error) => Error::Db(error), + crate::auth_graph::ChainTraversalError::LimitExceeded => { + Error::InvalidDelegation(DelegationError::ChainTraversalLimitExceeded) + } + })?; + &loaded_graph + } else { + loaded_graph = crate::auth_graph::AuthGraphSnapshot::load(db, &parent_ids) + .await + .map_err(|error| match error { + crate::auth_graph::ChainTraversalError::Db(error) => Error::Db(error), + crate::auth_graph::ChainTraversalError::LimitExceeded => { + Error::InvalidDelegation(DelegationError::ChainTraversalLimitExceeded) + } + })?; + &loaded_graph + }; let mut all_parents = Vec::with_capacity(delegation.parents.len()); for parent_cid in &delegation.parents { - let Some(parent) = Entity::find_by_id(Hash::from(*parent_cid)).one(db).await? - else { + let id = Hash::from(*parent_cid); + let Some(parent) = graph.delegation(&id).cloned() else { return Err(DelegationError::MissingParents.into()); }; if !policy_session && parent.delegatee != delegation.delegator { @@ -509,8 +569,36 @@ async fn validate_with( // A revoked grant cannot authorize new descendants. Check both // the directly cited parents and their transitive ancestors on // every registration so revocation takes effect immediately. + let verifier_now = OffsetDateTime::now_utc(); for parent in &all_parents { - ensure_parent_active(db, &parent.id).await?; + if graph.is_revoked(&parent.id) { + return Err( + DelegationError::ParentRevoked(parent.id.to_cid(0x55).to_string()).into(), + ); + } + if let Some(ancestor_cid) = graph.first_revoked_ancestor(&parent.id) { + return Err(DelegationError::AncestorRevoked { + ancestor_cid, + parent_cid: parent.id.to_cid(0x55).to_string(), + } + .into()); + } + for chain_id in graph.chain_ids_from(&parent.id) { + if graph.delegation(&chain_id).is_none() { + return Err(DelegationError::MissingParents.into()); + } + } + let effective_expiry = graph + .effective_expiry(&parent.id) + .and_then(|expiry| OffsetDateTime::from_unix_timestamp(expiry).ok()) + .or(parent.expiry); + if effective_expiry.is_some_and(|expiry| verifier_now >= expiry) + || parent + .not_before + .is_some_and(|not_before| verifier_now < not_before) + { + return Err(DelegationError::InvalidTime.into()); + } } let policy_root_parent = all_parents.iter().any(|parent| { @@ -566,10 +654,14 @@ async fn validate_with( .filter(|p| { // valid time bounds: child's validity must be within parent's validity // expiry: child must expire at or before parent (None = no expiry) - let expiry_valid = match (&p.expiry, &delegation.expiry) { - (None, _) => true, // parent never expires, any child expiry is valid - (Some(_), None) => false, // parent expires but child doesn't - invalid - (Some(pe), Some(de)) => *de <= *pe, // child must expire at or before parent + let parent_effective_expiry = graph + .effective_expiry(&p.id) + .and_then(|expiry| OffsetDateTime::from_unix_timestamp(expiry).ok()) + .or(p.expiry); + let expiry_valid = match (parent_effective_expiry, delegation.expiry) { + (None, _) => true, + (Some(_), None) => false, + (Some(parent_end), Some(child_end)) => child_end <= parent_end, }; // not_before: child must become valid at or after parent (None = valid immediately) let not_before_valid = match (&p.not_before, &delegation.not_before) { @@ -1025,6 +1117,7 @@ fn parent_is_terminal(p: &Model) -> bool { .unwrap_or(false) } +#[cfg(test)] async fn ensure_parent_active(db: &C, parent_id: &Hash) -> Result<(), Error> { if revocation::is_revoked(db, parent_id).await? { return Err(DelegationError::ParentRevoked(parent_id.to_cid(0x55).to_string()).into()); @@ -1798,7 +1891,7 @@ mod tests { for delegator in [KEY_OWNER, OTHER_DID] { for resource in [&key, &prefix] { let info = key_grant_info(delegator, resource, KEY_DECRYPT, &[]); - let refused = validate_with(&db, &info, false, false).await; + let refused = validate_with(&db, &info, false, false, None).await; assert!( matches!( refused, @@ -1815,15 +1908,175 @@ mod tests { "named_keys_unavailable: named keys are not enabled on this node" ); let info = key_grant_info(KEY_OWNER, &network, KEY_DECRYPT, &[]); - validate_with(&db, &info, false, false) + validate_with(&db, &info, false, false, None) .await .expect("the owner's bare network grant is not a named-key grant"); let info = key_grant_info(KEY_OWNER, &key, KEY_DECRYPT, &[]); - validate_with(&db, &info, false, true) + validate_with(&db, &info, false, true, None) .await .expect("the same grant is admitted with named keys on"); } + #[tokio::test] + async fn child_delegation_uses_renewed_parent_effective_expiry() { + use crate::keys::Secrets; + use sea_orm::sea_query::Value; + use sea_orm::{ConnectionTrait, DbBackend, Statement}; + use time::OffsetDateTime; + + fn db_datetime(value: OffsetDateTime) -> time::PrimitiveDateTime { + let utc = value.to_offset(time::UtcOffset::UTC); + utc.date().with_time(utc.time()) + } + + let db = database().await; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let original = crate::hash::hash(b"renewal-child-original"); + let lease = crate::hash::hash(b"renewal-child-lease"); + let ancestor = crate::hash::hash(b"renewal-child-ancestor"); + let original_expiry = OffsetDateTime::from_unix_timestamp(now - 60).unwrap(); + let lease_expiry = OffsetDateTime::from_unix_timestamp(now + 86_400).unwrap(); + let ancestor_expiry = OffsetDateTime::from_unix_timestamp(now + 7_200).unwrap(); + let prefix = "tinycloud:did:key:other:files/kv"; + insert_parent_grant( + &db, + ancestor, + "did:key:other", + "did:key:other", + prefix, + "tinycloud.kv/get", + ) + .await; + insert_parent_grant( + &db, + original, + "did:key:other", + "did:key:actor", + prefix, + "tinycloud.kv/get", + ) + .await; + insert_delegation(&db, lease, "did:key:other", "did:key:lease").await; + for (id, expiry) in [ + (original, original_expiry), + (lease, lease_expiry), + (ancestor, ancestor_expiry), + ] { + db.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "UPDATE delegation SET expiry = $1 WHERE id = $2", + [ + Value::TimeDateTime(Some(Box::new(db_datetime(expiry)))), + Value::Bytes(Some(Box::new(Vec::::from(id)))), + ], + )) + .await + .unwrap(); + db.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO delegation_cid_alias (id, canonical, encrypted) VALUES ($1,$1,FALSE)", + [Value::Bytes(Some(Box::new(Vec::::from(id))))], + )) + .await + .unwrap(); + } + for child in [original, lease] { + db.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO parent_delegation (parent, child) VALUES ($1,$2)", + [ + Value::Bytes(Some(Box::new(Vec::::from(ancestor)))), + Value::Bytes(Some(Box::new(Vec::::from(child)))), + ], + )) + .await + .unwrap(); + } + let secret = crate::keys::StaticSecret::new(vec![7; 32]).unwrap(); + let stable_did = secret.renewal_node_did().unwrap(); + let (renewer_did, epoch, audience) = crate::renewal::renewal_identity(&secret); + let original_cid = original.to_cid(0x55).to_string(); + let lease_cid = lease.to_cid(0x55).to_string(); + let end = now + 1_800; + let end_text = OffsetDateTime::from_unix_timestamp(end) + .unwrap() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + let signer = crate::renewal::RenewalSigner::from_seed( + secret.derive_key(crate::renewal::RENEWER_KEY_CONTEXT), + stable_did.clone(), + epoch, + ); + let (signed_bytes, signature, stamp_cid) = signer + .sign_stamp(&original_cid, &lease_cid, 1, &lease_cid, &end_text) + .unwrap(); + db.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO renewal_lease (original_canonical, lease_cid, state, generation, grantor, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key) VALUES ($1,$2,'active',1,'did:key:other',$3,$4,$5,$6,2592000,$7)", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::String(Some(Box::new(renewer_did.clone()))), + Value::BigInt(Some(epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(lease_expiry)))), + Value::TimeDateTime(Some(Box::new(db_datetime(original_expiry)))), + Value::Bytes(Some(Box::new(audience.to_vec()))), + ], + )).await.unwrap(); + db.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO renewal_stamp (cid, original_canonical, lease_cid, generation, previous_stamp_cid, renewer_epoch, new_end, signed_bytes, signature, replay_hash) VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9)", + [ + Value::Bytes(Some(Box::new(Vec::::from(crate::hash::hash(&signed_bytes))))), + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::BigInt(Some(epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(OffsetDateTime::from_unix_timestamp(end).unwrap())))), + Value::Bytes(Some(Box::new(signed_bytes.to_vec()))), + Value::Bytes(Some(Box::new(signature))), + Value::Bytes(Some(Box::new(vec![1; 32]))), + ], + )).await.unwrap(); + + let child_resource = "tinycloud:did:key:other:files/kv/doc"; + let mut child = key_grant_info( + "did:key:actor", + child_resource, + "tinycloud.kv/get", + &[original], + ); + child.expiry = Some(OffsetDateTime::from_unix_timestamp(now + 1_200).unwrap()); + assert!(validate_with(&db, &child, false, true, None).await.is_err()); + validate_with( + &db, + &child, + false, + true, + Some((&stable_did, &renewer_did, epoch)), + ) + .await + .unwrap(); + let renewer_grant = key_grant_info(&renewer_did, child_resource, "tinycloud.kv/get", &[]); + assert!(matches!( + validate_with( + &db, + &renewer_grant, + false, + true, + Some((&stable_did, &renewer_did, epoch)), + ) + .await, + Err(Error::InvalidDelegation( + DelegationError::UnauthorizedDelegator(_) + )) + )); + assert_eq!( + stamp_cid, + crate::hash::hash(&signed_bytes).to_cid(0x55).to_string() + ); + } + /// key.delete is refused even when the owner issues it. #[tokio::test] async fn owner_issued_key_delete_is_refused() { @@ -2310,7 +2563,7 @@ mod tests { for delegator in [KEY_OWNER, OTHER_DID] { for resource in [app_key(), app_prefix()] { let info = key_grant_info(delegator, &resource, KEY_DECRYPT, &[]); - let refused = validate_with(&db, &info, false, false).await; + let refused = validate_with(&db, &info, false, false, None).await; assert!( matches!( refused, diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index a835a23d..0d97fcc0 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -508,8 +508,11 @@ async fn validate_capabilities( let Some(ancestor) = graph.delegation(&chain_id) else { return Err(InvocationError::MissingParents.into()); }; - if ancestor - .expiry + let effective_expiry = graph + .effective_expiry(&chain_id) + .and_then(|expiry| OffsetDateTime::from_unix_timestamp(expiry).ok()) + .or(ancestor.expiry); + if effective_expiry .map(|expiry| chain_now >= expiry) .unwrap_or(false) || ancestor @@ -535,7 +538,11 @@ async fn validate_capabilities( let parents: Vec<_> = parents .into_iter() .filter(|(p, _)| { - p.expiry.map(|pexp| now < pexp).unwrap_or(true) + let effective_expiry = graph + .effective_expiry(&p.id) + .and_then(|expiry| OffsetDateTime::from_unix_timestamp(expiry).ok()) + .or(p.expiry); + effective_expiry.map(|pexp| now < pexp).unwrap_or(true) && p.not_before.map(|pnbf| now >= pnbf).unwrap_or(true) }) .collect(); diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index 814a3586..90bcf5d6 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -3,12 +3,12 @@ use crate::encryption_network::NetworkId; use crate::hash::{hash, Hash}; use crate::models::did_resolution::{verify_ucan_signature, UcanVerifyError}; use crate::types::Resource; -use sea_orm::Condition; use sea_orm::{ entity::prelude::*, sea_query::{Alias, Expr, JoinType, OnConflict, Query, SimpleExpr, UnionType}, ConnectionTrait, FromQueryResult, QueryOrder, QuerySelect, }; +use sea_orm::{Condition, Statement}; use std::collections::HashSet; use time::OffsetDateTime; use tinycloud_auth::{ @@ -207,6 +207,7 @@ pub async fn is_revoked(db: &C, delegation_id: &Hash) -> Res .is_some()) } +#[cfg(test)] pub(crate) async fn first_revoked_ancestor( db: &C, start: &Hash, @@ -375,13 +376,14 @@ pub(crate) async fn ancestor_chain_ids_for_roots( pub(crate) enum ControlAction { Revoke, Status, + Renew, } - impl ControlAction { fn ability(self) -> &'static str { match self { Self::Revoke => "tinycloud.delegation/revoke", Self::Status => "tinycloud.delegation/status", + Self::Renew => "tinycloud.delegation/renew", } } @@ -528,6 +530,25 @@ pub(crate) async fn control_proof_decision( proofs: &[Cid], action: ControlAction, target: &Hash, +) -> Result { + control_proof_decision_at( + db, + signer, + proofs, + action, + target, + OffsetDateTime::now_utc(), + ) + .await +} + +pub(crate) async fn control_proof_decision_at( + db: &C, + signer: &str, + proofs: &[Cid], + action: ControlAction, + target: &Hash, + now: OffsetDateTime, ) -> Result { if proofs.is_empty() { return Ok(ControlProofDecision::DirectSigner(signer.to_string())); @@ -536,8 +557,19 @@ pub(crate) async fn control_proof_decision( return Ok(ControlProofDecision::Denied); } - let now = OffsetDateTime::now_utc(); let proof_id = Hash::from(proofs[0]); + let renewal_lease_proof = db + .query_one(Statement::from_sql_and_values( + db.get_database_backend(), + "SELECT 1 AS lease FROM renewal_lease WHERE lease_cid = $1 LIMIT 1", + [sea_orm::sea_query::Value::Bytes(Some(Box::new( + proof_id.as_ref().to_vec(), + )))], + )) + .await?; + if renewal_lease_proof.is_some() && action != ControlAction::Renew { + return Ok(ControlProofDecision::Denied); + } let Some(parent) = delegation::Entity::find_by_id(proof_id).one(db).await? else { return Ok(ControlProofDecision::Denied); }; @@ -1233,6 +1265,72 @@ mod tests { assert!(!revoker_may_revoke(&db, target, "did:key:session", proof).await); } + #[tokio::test] + async fn renewal_lease_is_scoped_to_the_renew_action() { + use sea_orm::{ConnectionTrait, Statement}; + + let db = database().await; + for did in ["did:key:owner", "did:key:renewer", "did:key:holder"] { + insert_actor(&db, did).await; + } + let original = hash(b"renewal-original"); + let lease = hash(b"renewal-lease"); + let original_cid = original.to_cid(0x55).to_string(); + let lease_cid = lease.to_cid(0x55).to_string(); + insert_delegation_between(&db, original, "did:key:owner", "did:key:holder").await; + insert_delegation_between(&db, lease, "did:key:owner", "did:key:renewer").await; + insert_ability( + &db, + lease, + &format!("urn:cid:{original_cid}"), + "tinycloud.delegation/renew", + ) + .await; + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO renewal_lease (original_canonical, lease_cid, state, generation, grantor, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key) VALUES ($1,$2,'active',1,'did:key:owner','did:key:renewer',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,2592000,$3)", + [ + sea_orm::Value::Bytes(Some(Box::new(original.as_ref().to_vec()))), + sea_orm::Value::Bytes(Some(Box::new(lease.as_ref().to_vec()))), + sea_orm::Value::Bytes(Some(Box::new(vec![7; 32]))), + ], + )) + .await + .unwrap(); + + let target = delegation::Entity::find_by_id(original) + .one(&db) + .await + .unwrap() + .unwrap(); + let decision = control_proof_decision( + &db, + "did:key:renewer", + &[lease_cid.parse().unwrap()], + ControlAction::Renew, + &original, + ) + .await + .unwrap(); + let ControlProofDecision::Scoped(control) = decision else { + panic!("renewal lease must provide its direct Renew authority"); + }; + assert!(control.directly_authorizes_cid(&original, &target)); + + for action in [ControlAction::Revoke, ControlAction::Status] { + let decision = control_proof_decision( + &db, + "did:key:renewer", + &[lease_cid.parse().unwrap()], + action, + &original, + ) + .await + .unwrap(); + assert!(matches!(decision, ControlProofDecision::Denied)); + } + } + async fn insert_ability( db: &C, delegation: Hash, diff --git a/tinycloud-core/src/renewal.rs b/tinycloud-core/src/renewal.rs new file mode 100644 index 00000000..3e55fbae --- /dev/null +++ b/tinycloud-core/src/renewal.rs @@ -0,0 +1,2201 @@ +use crate::hash::Hash; +use crate::keys::StaticSecret; +use sea_orm::sea_query::Value; +use sea_orm::{ConnectionTrait, DatabaseBackend, DbErr, Statement, TransactionTrait}; +use std::str::FromStr; +use time::OffsetDateTime; +use tinycloud_auth::ipld_core::cid::Cid; + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use thiserror::Error; + +pub const STAMP_DOMAIN: &str = "tinycloud/node/renewal-stamp/v1"; +pub const RENEWER_KEY_CONTEXT: &[u8] = b"tinycloud/node/renewer/v1"; +const MAX_LEASE_SECONDS: i64 = 365 * 24 * 60 * 60; +const MAX_PERIOD_SECONDS: i64 = 10 * 365 * 24 * 60 * 60; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OriginalFormat { + Ucan, + Cacao, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum RenewalError { + #[error("renewal period has no valid signed anchor")] + MissingAnchor, + #[error("renewal period is not a positive finite duration within ten years")] + InvalidPeriod, + #[error("renewal end timestamp overflowed")] + TimestampOverflow, + #[error("renewal stamp end exceeds a signed validity bound")] + StampEndOutOfBounds, + #[error("renewal stamp signature is invalid for the lease audience")] + InvalidSignature, +} + +#[derive(Debug, thiserror::Error)] +pub enum RenewalStoreError { + #[error(transparent)] + Db(#[from] DbErr), + #[error(transparent)] + Invalid(#[from] RenewalError), + #[error("renewal lease not found or inactive")] + LeaseUnavailable, + #[error("renewal request replayed")] + Replay, + #[error("renewal lease is revoked")] + Revoked, + #[error("renewal identity epoch changed")] + IdentityEpoch, + #[error("renewal operation is not authorized")] + Unauthorized, + #[error("renewal input is invalid")] + InvalidInput, +} + +/// Stamp issue request identity; signed content and bounds are computed in core. +#[derive(Clone, Debug)] +pub struct StampIssue { + pub invocation: crate::util::InvocationInfo, + pub original_cid: String, + pub lease_cid: String, + pub replay_hash: Hash, + pub replay_expires_at: i64, +} + +/// The node identity persisted during startup and held constant for this +/// process lifetime. Request paths reuse this snapshot instead of querying +/// the identity row on every authorization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RenewalNodeIdentity { + pub stable_node_did: String, + pub renewer_did: String, + pub epoch: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LeaseRecord { + pub original_cid: String, + pub lease_cid: String, + pub grantor: String, + pub renewer_did: String, + pub renewer_epoch: u64, + pub lease_expiry: i64, + pub period_seconds: u64, + pub state: String, + pub generation: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StampRecord { + pub cid: String, + pub lease_cid: String, + pub new_end: i64, + pub previous_stamp_cid: String, + pub generation: u64, + pub signed_bytes: Vec, + pub signature: Vec, +} + +pub fn period_seconds( + expiry: i64, + not_before: Option, + issued_at: Option, + format: OriginalFormat, +) -> Result { + let anchor = match (not_before, format, issued_at) { + (Some(not_before), _, _) => not_before, + (None, OriginalFormat::Cacao, Some(issued_at)) => issued_at, + _ => return Err(RenewalError::MissingAnchor), + }; + expiry + .checked_sub(anchor) + .filter(|period| *period > 0 && *period <= MAX_PERIOD_SECONDS) + .ok_or(RenewalError::InvalidPeriod) +} + +pub fn new_end( + now: i64, + period: i64, + lease_expiry: i64, + ancestor_ends: &[i64], +) -> Result { + if period <= 0 { + return Err(RenewalError::InvalidPeriod); + } + let period_end = now + .checked_add(period) + .ok_or(RenewalError::TimestampOverflow)?; + Ok(ancestor_ends + .iter() + .copied() + .fold(period_end.min(lease_expiry), i64::min)) +} + +pub fn validate_stamp_end( + stamp_end: i64, + verifier_now: i64, + period: i64, + lease_expiry: i64, + ancestor_ends: &[i64], +) -> Result<(), RenewalError> { + let latest = verifier_now + .checked_add(period) + .ok_or(RenewalError::TimestampOverflow)?; + if stamp_end <= 0 + || period <= 0 + || stamp_end > lease_expiry + || stamp_end > latest + || ancestor_ends.iter().any(|end| stamp_end > *end) + { + return Err(RenewalError::StampEndOutOfBounds); + } + Ok(()) +} + +pub fn verify_stamp_signature( + signed_bytes: &[u8], + signature_bytes: &[u8], + lease_audience_public_key: &[u8; 32], +) -> Result<(), RenewalError> { + let key = VerifyingKey::from_bytes(lease_audience_public_key) + .map_err(|_| RenewalError::InvalidSignature)?; + let signature = + Signature::from_slice(signature_bytes).map_err(|_| RenewalError::InvalidSignature)?; + key.verify(signed_bytes, &signature) + .map_err(|_| RenewalError::InvalidSignature) +} +pub(crate) struct SnapshotStampVerification<'a> { + pub(crate) signed_bytes: &'a [u8], + pub(crate) signature_bytes: &'a [u8], + pub(crate) expected_stamp_cid: &'a str, + pub(crate) expected_original_cid: &'a str, + pub(crate) expected_lease_cid: &'a str, + pub(crate) generation: u64, + pub(crate) previous_stamp_cid: &'a str, + pub(crate) new_end: i64, + pub(crate) node_did: &'a str, + pub(crate) renewer_epoch: u64, + pub(crate) audience_public_key: &'a [u8; 32], + pub(crate) verifier_now: i64, + pub(crate) period: i64, + pub(crate) lease_expiry: i64, + pub(crate) ancestor_ends: &'a [i64], +} + +/// Verify the frozen signed stamp representation against its registered chain. +pub(crate) fn verify_snapshot_stamp( + input: SnapshotStampVerification<'_>, +) -> Result<(), RenewalStoreError> { + let SnapshotStampVerification { + signed_bytes, + signature_bytes, + expected_stamp_cid, + expected_original_cid, + expected_lease_cid, + generation, + previous_stamp_cid, + new_end, + node_did, + renewer_epoch, + audience_public_key, + verifier_now, + period, + lease_expiry, + ancestor_ends, + } = input; + verify_stamp_signature(signed_bytes, signature_bytes, audience_public_key)?; + let signed: CanonicalRenewalStamp<'_> = serde_ipld_dagcbor::from_slice(signed_bytes) + .map_err(|_| RenewalStoreError::InvalidInput)?; + if serde_ipld_dagcbor::to_vec(&signed).map_err(|_| RenewalStoreError::InvalidInput)? + != signed_bytes + { + return Err(RenewalStoreError::InvalidInput); + } + let unsigned = &signed.unsigned; + let (expected_stamp_cid, _) = canonical_cid(expected_stamp_cid)?; + let (expected_original_cid, _) = canonical_cid(expected_original_cid)?; + let (expected_lease_cid, _) = canonical_cid(expected_lease_cid)?; + let (previous_stamp_cid, _) = canonical_cid(previous_stamp_cid)?; + let end_string = unix_time(new_end)? + .format(&time::format_description::well_known::Rfc3339) + .map_err(|_| RenewalStoreError::InvalidInput)?; + if signed.domain != STAMP_DOMAIN + || unsigned.stamp_type != "TinyCloudRenewalStamp" + || unsigned.version != 1 + || unsigned.original_cid != expected_original_cid + || unsigned.lease_cid != expected_lease_cid + || unsigned.lease_generation != generation + || unsigned.previous_stamp_cid != previous_stamp_cid + || unsigned.node_did != node_did + || unsigned.renewer_epoch != renewer_epoch + || unsigned.new_end != end_string + || crate::hash::hash(signed_bytes).to_cid(0x55).to_string() != expected_stamp_cid + { + return Err(RenewalStoreError::InvalidInput); + } + validate_stamp_end(new_end, verifier_now, period, lease_expiry, ancestor_ends)?; + Ok(()) +} + +// This declaration order is the frozen canonical DAG-CBOR key order. +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +struct CanonicalRenewalStamp<'a> { + #[serde(borrow)] + domain: &'a str, + unsigned: CanonicalRenewalStampUnsigned<'a>, +} + +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CanonicalRenewalStampUnsigned<'a> { + #[serde(rename = "type", borrow)] + stamp_type: &'a str, + #[serde(borrow)] + new_end: &'a str, + #[serde(borrow)] + node_did: &'a str, + version: u64, + #[serde(borrow)] + lease_cid: &'a str, + #[serde(borrow)] + original_cid: &'a str, + renewer_epoch: u64, + lease_generation: u64, + #[serde(borrow)] + previous_stamp_cid: &'a str, +} + +pub struct RenewalSigner { + key: SigningKey, + pub node_did: String, + pub renewer_epoch: u64, +} + +impl RenewalSigner { + pub fn from_seed(seed: [u8; 32], node_did: String, renewer_epoch: u64) -> Self { + Self { + key: SigningKey::from_bytes(&seed), + node_did, + renewer_epoch, + } + } + + pub fn public_key(&self) -> [u8; 32] { + self.key.verifying_key().to_bytes() + } + + /// Serialize the frozen v1 map and sign exactly those canonical bytes. + pub fn sign_stamp( + &self, + original_cid: &str, + lease_cid: &str, + lease_generation: u64, + previous_stamp_cid: &str, + new_end: &str, + ) -> Result< + (Vec, Vec, String), + serde_ipld_dagcbor::EncodeError, + > { + let signed = CanonicalRenewalStamp { + domain: STAMP_DOMAIN, + unsigned: CanonicalRenewalStampUnsigned { + stamp_type: "TinyCloudRenewalStamp", + new_end, + node_did: &self.node_did, + version: 1, + lease_cid, + original_cid, + renewer_epoch: self.renewer_epoch, + lease_generation, + previous_stamp_cid, + }, + }; + let bytes = serde_ipld_dagcbor::to_vec(&signed)?; + let signature = self.key.sign(&bytes).to_bytes().to_vec(); + let cid = crate::hash::hash(&bytes).to_cid(0x55).to_string(); + Ok((bytes, signature, cid)) + } +} + +/// Return the dedicated node renewer DID, initial epoch, and raw public key. +pub fn renewal_identity(secret: &StaticSecret) -> (String, u64, [u8; 32]) { + renewal_identity_at_epoch(secret, 1) +} + +pub fn renewal_identity_at_epoch(secret: &StaticSecret, epoch: u64) -> (String, u64, [u8; 32]) { + renewal_identity_from_seed_at_epoch(secret.derive_key(RENEWER_KEY_CONTEXT), epoch) +} + +pub fn renewal_identity_from_seed(seed: [u8; 32]) -> (String, u64, [u8; 32]) { + renewal_identity_from_seed_at_epoch(seed, 1) +} + +pub fn renewal_identity_from_seed_at_epoch(seed: [u8; 32], epoch: u64) -> (String, u64, [u8; 32]) { + let signer = SigningKey::from_bytes(&seed); + let bytes = signer.verifying_key().to_bytes(); + let public = libp2p::identity::ed25519::PublicKey::try_from_bytes(&bytes) + .expect("32-byte Ed25519 public key"); + let did = crate::keys::public_key_to_did_key(public.into()); + (did, epoch, bytes) +} + +pub fn is_renewer_did(secret: &StaticSecret, did: &str) -> bool { + renewal_identity(secret).0 == did +} + +pub fn is_renewal_capability(ability: &str, resource: &str, original_cid: &str) -> bool { + ability == "tinycloud.delegation/renew" + && resource == format!("urn:cid:{original_cid}") + && canonical_cid(original_cid).is_ok() +} +/// Persist and return the current renewer identity during node startup. +pub async fn initialize_renewal_identity( + conn: &C, + seed: [u8; 32], +) -> Result<(String, u64, [u8; 32]), RenewalStoreError> { + let (current_did, _, public_key) = renewal_identity_from_seed(seed); + let tx = crate::db::begin_write_transaction(conn, None).await?; + let backend = tx.get_database_backend(); + tx.execute_unprepared( + "INSERT INTO renewal_identity (id, renewer_did, epoch) VALUES (1, NULL, 0) ON CONFLICT (id) DO NOTHING", + ) + .await?; + let query = match backend { + DatabaseBackend::Postgres => { + "SELECT renewer_did, epoch FROM renewal_identity WHERE id = 1 FOR UPDATE" + } + DatabaseBackend::Sqlite => "SELECT renewer_did, epoch FROM renewal_identity WHERE id = 1", + DatabaseBackend::MySql => return Err(RenewalStoreError::InvalidInput), + }; + let row = tx + .query_one(Statement::from_string(backend, query)) + .await? + .ok_or(RenewalStoreError::InvalidInput)?; + let previous_did: Option = row.try_get("", "renewer_did")?; + let previous_epoch: i64 = row.try_get("", "epoch")?; + let epoch = match previous_did.as_deref() { + None if previous_epoch == 0 => 1, + Some(previous) if previous == current_did && previous_epoch > 0 => previous_epoch as u64, + Some(_) if previous_epoch > 0 && previous_epoch < i64::MAX => previous_epoch as u64 + 1, + _ => return Err(RenewalStoreError::InvalidInput), + }; + if previous_did.as_deref() != Some(current_did.as_str()) { + tx.execute(Statement::from_sql_and_values( + backend, + "UPDATE renewal_identity SET renewer_did = $1, epoch = $2 WHERE id = 1", + [ + Value::String(Some(Box::new(current_did.clone()))), + Value::BigInt(Some(epoch as i64)), + ], + )) + .await?; + } + tx.commit().await?; + Ok((current_did, epoch, public_key)) +} + +pub async fn current_renewal_identity( + db: &C, + secret: &StaticSecret, +) -> Result<(String, u64, [u8; 32]), RenewalStoreError> { + current_renewal_identity_from_seed(db, secret.derive_key(RENEWER_KEY_CONTEXT)).await +} + +pub async fn current_renewal_identity_from_seed( + db: &C, + seed: [u8; 32], +) -> Result<(String, u64, [u8; 32]), RenewalStoreError> { + let backend = db.get_database_backend(); + let query = match backend { + DatabaseBackend::Postgres => { + "SELECT renewer_did, epoch FROM renewal_identity WHERE id = 1 FOR SHARE" + } + DatabaseBackend::Sqlite => "SELECT renewer_did, epoch FROM renewal_identity WHERE id = 1", + DatabaseBackend::MySql => return Err(RenewalStoreError::InvalidInput), + }; + let row = db + .query_one(Statement::from_string(backend, query)) + .await? + .ok_or(RenewalStoreError::IdentityEpoch)?; + let stored_did: Option = row.try_get("", "renewer_did")?; + let epoch: i64 = row.try_get("", "epoch")?; + let (derived_did, _, public_key) = renewal_identity_from_seed(seed); + if stored_did.as_deref() != Some(derived_did.as_str()) || epoch <= 0 { + return Err(RenewalStoreError::IdentityEpoch); + } + Ok((derived_did, epoch as u64, public_key)) +} + +pub async fn lease_for_original( + db: &C, + original_cid: &str, + identity: &RenewalNodeIdentity, +) -> Result, RenewalStoreError> { + lease_for_original_at( + db, + original_cid, + OffsetDateTime::now_utc().unix_timestamp(), + identity, + ) + .await +} + +async fn lease_for_original_at( + db: &C, + original_cid: &str, + verifier_now: i64, + identity: &RenewalNodeIdentity, +) -> Result, RenewalStoreError> { + let (original_cid, original) = canonical_cid(original_cid)?; + if is_revoked_chain(db, &original).await? { + return Ok(None); + } + let now = db_datetime(verifier_now)?; + let epoch = i64::try_from(identity.epoch).map_err(|_| RenewalStoreError::InvalidInput)?; + let rows = db + .query_all(Statement::from_sql_and_values( + db.get_database_backend(), + "SELECT lease_cid, grantor, renewer_did, renewer_epoch, lease_expiry, period_seconds, state, generation FROM renewal_lease WHERE original_canonical = $1 AND state = 'active' AND lease_expiry > $2 AND renewer_did = $3 AND renewer_epoch = $4 ORDER BY lease_cid", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::TimeDateTime(Some(Box::new(now))), + Value::String(Some(Box::new(identity.renewer_did.clone()))), + Value::BigInt(Some(epoch)), + ], + )) + .await?; + let mut selected = None; + for row in rows { + let lease_bytes: Vec = row.try_get("", "lease_cid")?; + let lease = Hash::try_from(lease_bytes).map_err(DbErr::from)?; + if is_revoked_chain(db, &lease).await? { + continue; + } + if selected.is_some() { + return Err(RenewalStoreError::InvalidInput); + } + let expiry: OffsetDateTime = row.try_get("", "lease_expiry")?; + selected = Some(LeaseRecord { + original_cid: original_cid.clone(), + lease_cid: lease.to_cid(0x55).to_string(), + grantor: row.try_get("", "grantor")?, + renewer_did: row.try_get("", "renewer_did")?, + renewer_epoch: row.try_get::("", "renewer_epoch")? as u64, + lease_expiry: expiry.unix_timestamp(), + period_seconds: row.try_get::("", "period_seconds")? as u64, + state: row.try_get("", "state")?, + generation: row.try_get::("", "generation")? as u64, + }); + } + Ok(selected) +} +/// Lists active leases for the identity captured during node startup. +pub async fn active_leases_for_current_epoch( + db: &C, + identity: &RenewalNodeIdentity, +) -> Result, RenewalStoreError> { + let backend = db.get_database_backend(); + let verifier_now = OffsetDateTime::now_utc().unix_timestamp(); + let now = db_datetime(verifier_now)?; + let epoch = i64::try_from(identity.epoch).map_err(|_| RenewalStoreError::InvalidInput)?; + let rows = db + .query_all(Statement::from_sql_and_values( + backend, + "SELECT DISTINCT original_canonical FROM renewal_lease WHERE state = 'active' AND lease_expiry > $1 AND renewer_did = $2 AND renewer_epoch = $3 ORDER BY original_canonical", + [ + Value::TimeDateTime(Some(Box::new(now))), + Value::String(Some(Box::new(identity.renewer_did.clone()))), + Value::BigInt(Some(epoch)), + ], + )) + .await?; + let mut leases = Vec::with_capacity(rows.len()); + for row in rows { + let bytes: Vec = row.try_get("", "original_canonical")?; + let original = Hash::try_from(bytes).map_err(DbErr::from)?; + let original_cid = original.to_cid(0x55).to_string(); + if let Some(lease) = + lease_for_original_at(db, &original_cid, verifier_now, identity).await? + { + leases.push(lease); + } + } + Ok(leases) +} + +pub async fn authorize_renewal_invocation( + db: &C, + invocation: &crate::util::InvocationInfo, + original_cid: &str, + identity: &RenewalNodeIdentity, +) -> Result<(), RenewalStoreError> { + authorize_renewal_invocation_at( + db, + invocation, + original_cid, + identity, + OffsetDateTime::now_utc().unix_timestamp(), + ) + .await +} + +async fn authorize_renewal_invocation_at( + db: &C, + invocation: &crate::util::InvocationInfo, + original_cid: &str, + identity: &RenewalNodeIdentity, + verifier_now: i64, +) -> Result<(), RenewalStoreError> { + use crate::models::revocation::{ControlAction, ControlProofDecision}; + if invocation.capabilities.len() != 1 + || !invocation.capabilities[0].caveats.0.is_empty() + || !is_renewal_capability( + invocation.capabilities[0].ability.as_ref().as_ref(), + &invocation.capabilities[0].resource.to_string(), + original_cid, + ) + { + return Err(RenewalStoreError::Unauthorized); + } + let lease = lease_for_original_at(db, original_cid, verifier_now, identity) + .await? + .ok_or(RenewalStoreError::LeaseUnavailable)?; + if invocation.invoker != lease.renewer_did { + return Err(RenewalStoreError::Unauthorized); + } + let lease_parent = + Cid::from_str(&lease.lease_cid).map_err(|_| RenewalStoreError::InvalidInput)?; + if invocation.parents.as_slice() != [lease_parent] { + return Err(RenewalStoreError::Unauthorized); + } + let (_, target) = canonical_cid(original_cid)?; + if is_revoked_chain(db, &target).await? { + return Err(RenewalStoreError::Revoked); + } + let original = crate::models::revocation::target_for_cid(db, target) + .await? + .ok_or(RenewalStoreError::Unauthorized)?; + + match crate::models::revocation::control_proof_decision_at( + db, + &invocation.invoker, + &invocation.parents, + ControlAction::Renew, + &target, + unix_time(verifier_now)?, + ) + .await? + { + ControlProofDecision::Scoped(control) + if control.directly_authorizes_cid(&target, &original) => + { + Ok(()) + } + _ => Err(RenewalStoreError::Unauthorized), + } +} + +fn canonical_cid(value: &str) -> Result<(String, Hash), RenewalStoreError> { + let cid = Cid::from_str(value).map_err(|_| RenewalStoreError::InvalidInput)?; + let canonical = cid.to_string(); + if canonical != value { + return Err(RenewalStoreError::InvalidInput); + } + Ok((canonical, Hash::from(cid))) +} + +fn cid_hash_bytes(value: &str) -> Result, RenewalStoreError> { + Ok(Vec::::from(canonical_cid(value)?.1)) +} + +fn unix_time(value: i64) -> Result { + OffsetDateTime::from_unix_timestamp(value).map_err(|_| RenewalStoreError::InvalidInput) +} + +fn db_datetime(value: i64) -> Result { + let timestamp = unix_time(value)?.to_offset(time::UtcOffset::UTC); + Ok(timestamp.date().with_time(timestamp.time())) +} + +pub(crate) fn lease_original_from_signed( + lease: &crate::events::Delegation, +) -> Result, RenewalStoreError> { + let info = signed_delegation_info(lease.serialized_bytes())?; + let renew_capabilities = info + .capabilities + .iter() + .filter(|capability| capability.ability.as_ref().as_ref() == "tinycloud.delegation/renew") + .collect::>(); + if renew_capabilities.is_empty() { + return Ok(None); + } + if info.capabilities.len() != 1 + || renew_capabilities.len() != 1 + || !renew_capabilities[0].caveats.0.is_empty() + { + return Err(RenewalStoreError::Unauthorized); + } + let resource = renew_capabilities[0].resource.to_string(); + let original_cid = resource + .strip_prefix("urn:cid:") + .ok_or(RenewalStoreError::Unauthorized)?; + if !is_renewal_capability( + renew_capabilities[0].ability.as_ref().as_ref(), + &resource, + original_cid, + ) { + return Err(RenewalStoreError::Unauthorized); + } + Ok(Some(original_cid.to_owned())) +} + +fn signed_delegation_info(bytes: &[u8]) -> Result { + let signed = tinycloud_auth::authorization::TinyCloudDelegation::from_bytes(bytes) + .map_err(|_| RenewalStoreError::InvalidInput)?; + crate::util::DelegationInfo::try_from(signed).map_err(|_| RenewalStoreError::InvalidInput) +} + +async fn verify_signed_signature( + info: &crate::util::DelegationInfo, +) -> Result<(), RenewalStoreError> { + match &info.delegation { + tinycloud_auth::authorization::TinyCloudDelegation::Ucan(ucan) => { + crate::did_resolution::verify_ucan_signature(ucan) + .await + .map_err(|_| RenewalStoreError::InvalidInput)?; + } + tinycloud_auth::authorization::TinyCloudDelegation::Cacao(cacao) => { + cacao + .verify() + .await + .map_err(|_| RenewalStoreError::InvalidInput)?; + } + } + Ok(()) +} + +async fn load_renewal_snapshot( + db: &C, + roots: &[Hash], + stable_node_did: &str, + renewer_did: &str, + renewer_epoch: u64, + verifier_now: i64, +) -> Result { + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + db, + roots, + stable_node_did, + renewer_did, + renewer_epoch, + verifier_now, + ) + .await + .map_err(|error| match error { + crate::auth_graph::ChainTraversalError::Db(error) => RenewalStoreError::Db(error), + crate::auth_graph::ChainTraversalError::LimitExceeded => { + RenewalStoreError::LeaseUnavailable + } + }) +} + +fn live_chain_effective_ends( + snapshot: &crate::auth_graph::AuthGraphSnapshot, + root: &Hash, + now: i64, +) -> Result, RenewalStoreError> { + if snapshot.is_revoked(root) || snapshot.first_revoked_ancestor(root).is_some() { + return Err(RenewalStoreError::Revoked); + } + let chain = snapshot.chain_ids_from(root); + if chain.is_empty() { + return Err(RenewalStoreError::LeaseUnavailable); + } + let mut ends = Vec::with_capacity(chain.len()); + for id in chain { + let delegation = snapshot + .delegation(&id) + .ok_or(RenewalStoreError::LeaseUnavailable)?; + if delegation + .not_before + .is_some_and(|start| start.unix_timestamp() > now) + { + return Err(RenewalStoreError::LeaseUnavailable); + } + let end = snapshot + .effective_expiry(&id) + .ok_or(RenewalStoreError::LeaseUnavailable)?; + if end <= now { + return Err(RenewalStoreError::LeaseUnavailable); + } + ends.push((id, end)); + } + Ok(ends) +} + +pub(crate) async fn register_lease( + tx: &C, + lease_event: &crate::events::Delegation, + renewal_seed: [u8; 32], + renewer_epoch: u64, + stable_node_did: &str, + encryption: Option<&crate::encryption::ColumnEncryption>, +) -> Result<(), RenewalStoreError> { + register_lease_at( + tx, + lease_event, + renewal_seed, + renewer_epoch, + stable_node_did, + encryption, + OffsetDateTime::now_utc().unix_timestamp(), + ) + .await +} + +async fn register_lease_at( + tx: &C, + lease_event: &crate::events::Delegation, + renewal_seed: [u8; 32], + renewer_epoch: u64, + stable_node_did: &str, + encryption: Option<&crate::encryption::ColumnEncryption>, + now: i64, +) -> Result<(), RenewalStoreError> { + let lease_bytes = lease_event.serialized_bytes(); + let lease = signed_delegation_info(lease_bytes)?; + verify_signed_signature(&lease).await?; + let original_cid = + lease_original_from_signed(lease_event)?.ok_or(RenewalStoreError::Unauthorized)?; + let (original_cid, original) = canonical_cid(&original_cid)?; + let lease_hash = crate::hash::hash(lease_bytes); + + let lease = &lease; + + let original_row = tx.query_one(Statement::from_sql_and_values( + tx.get_database_backend(), + "SELECT serialization, serialization_format, delegator, delegatee, expiry FROM delegation WHERE id = $1", + [Value::Bytes(Some(Box::new(Vec::::from(original))))], + )).await?.ok_or(RenewalStoreError::InvalidInput)?; + let stored_bytes: Vec = original_row.try_get("", "serialization")?; + let stored_format: i16 = original_row.try_get("", "serialization_format")?; + let original_id_bytes = Vec::::from(original); + let original_bytes = crate::encryption::maybe_decrypt( + encryption, + &stored_bytes, + crate::encryption::RowBinding::new("delegation", "serialization", &[&original_id_bytes]), + stored_format, + ) + .map_err(|_| RenewalStoreError::InvalidInput)?; + if crate::hash::hash(&original_bytes) != original { + return Err(RenewalStoreError::InvalidInput); + } + let original_info = signed_delegation_info(&original_bytes)?; + verify_signed_signature(&original_info).await?; + let graph_delegator: String = original_row.try_get("", "delegator")?; + let graph_delegatee: String = original_row.try_get("", "delegatee")?; + let graph_expiry: Option = original_row.try_get("", "expiry")?; + if graph_delegator != original_info.delegator + || graph_delegatee != original_info.delegate + || graph_expiry != original_info.expiry + { + return Err(RenewalStoreError::InvalidInput); + } + let original_expiry = original_info + .expiry + .ok_or(RenewalStoreError::InvalidInput)?; + let period = period_seconds( + original_expiry.unix_timestamp(), + original_info.not_before.map(|value| value.unix_timestamp()), + original_info.issued_at.map(|value| value.unix_timestamp()), + match original_info.delegation { + tinycloud_auth::authorization::TinyCloudDelegation::Ucan(_) => OriginalFormat::Ucan, + tinycloud_auth::authorization::TinyCloudDelegation::Cacao(_) => OriginalFormat::Cacao, + }, + )?; + let lease_expiry = lease.expiry.ok_or(RenewalStoreError::InvalidInput)?; + let lease_format = match &lease.delegation { + tinycloud_auth::authorization::TinyCloudDelegation::Ucan(_) => OriginalFormat::Ucan, + tinycloud_auth::authorization::TinyCloudDelegation::Cacao(_) => OriginalFormat::Cacao, + }; + let lease_not_before = match (lease.not_before, lease_format, lease.issued_at) { + (Some(not_before), _, _) => not_before, + (None, OriginalFormat::Cacao, Some(issued_at)) => issued_at, + _ => return Err(RenewalStoreError::InvalidInput), + }; + let lease_duration = lease_expiry + .unix_timestamp() + .checked_sub(lease_not_before.unix_timestamp()) + .ok_or(RenewalStoreError::InvalidInput)?; + if period <= 0 + || period > MAX_PERIOD_SECONDS + || lease_not_before.unix_timestamp() <= 0 + || lease_duration <= 0 + || lease_duration > MAX_LEASE_SECONDS + { + return Err(RenewalStoreError::InvalidInput); + } + let (renewer_did, _, audience_public_key) = + renewal_identity_from_seed_at_epoch(renewal_seed, renewer_epoch); + if lease.delegator != original_info.delegator || lease.delegate != renewer_did { + return Err(RenewalStoreError::Unauthorized); + } + lock_original(tx, &original).await?; + + let lease_horizon = now + .checked_add(MAX_LEASE_SECONDS) + .ok_or(RenewalStoreError::InvalidInput)?; + if lease_expiry.unix_timestamp() > lease_horizon + || lease_not_before.unix_timestamp() > now + || lease_expiry.unix_timestamp() <= now + { + return Err(RenewalStoreError::InvalidInput); + } + let backend = tx.get_database_backend(); + let graph_rows = tx + .query_one(Statement::from_sql_and_values( + backend, + "SELECT delegator, delegatee, expiry FROM delegation WHERE id = $1", + [Value::Bytes(Some(Box::new(Vec::::from(lease_hash))))], + )) + .await? + .ok_or(RenewalStoreError::InvalidInput)?; + let graph_delegator: String = graph_rows.try_get("", "delegator")?; + let graph_delegatee: String = graph_rows.try_get("", "delegatee")?; + let graph_expiry: Option = graph_rows.try_get("", "expiry")?; + if graph_delegator != lease.delegator + || graph_delegatee != lease.delegate + || graph_expiry != lease.expiry + { + return Err(RenewalStoreError::InvalidInput); + } + let lease_abilities = tx + .query_all(Statement::from_sql_and_values( + backend, + "SELECT ability, resource, caveats FROM ability WHERE delegation = $1", + [Value::Bytes(Some(Box::new(Vec::::from(lease_hash))))], + )) + .await?; + if lease_abilities.len() != 1 + || !is_renewal_capability( + lease_abilities[0] + .try_get::("", "ability")? + .as_str(), + lease_abilities[0] + .try_get::("", "resource")? + .as_str(), + &original_cid, + ) + || !lease_abilities[0] + .try_get::("", "caveats")? + .0 + .is_empty() + { + return Err(RenewalStoreError::Unauthorized); + } + let existing = tx.query_one(Statement::from_sql_and_values( + backend, + "SELECT grantor, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key FROM renewal_lease WHERE original_canonical = $1 AND lease_cid = $2", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease_hash)))), + ], + )).await?; + if let Some(row) = existing { + let same = row.try_get::("", "grantor")? == lease.delegator + && row.try_get::("", "renewer_did")? == renewer_did + && row.try_get::("", "renewer_epoch")? == renewer_epoch as i64 + && row.try_get::("", "lease_expiry")? == lease_expiry + && row.try_get::("", "original_expiry")? == original_expiry + && row.try_get::("", "period_seconds")? == period + && row.try_get::>("", "audience_public_key")? == audience_public_key; + return if same { + Ok(()) + } else { + Err(RenewalStoreError::InvalidInput) + }; + } + + let snapshot = load_renewal_snapshot( + tx, + &[original, lease_hash], + stable_node_did, + &renewer_did, + renewer_epoch, + now, + ) + .await?; + live_chain_effective_ends(&snapshot, &original, now)?; + live_chain_effective_ends(&snapshot, &lease_hash, now)?; + let old_leases = tx + .query_all(Statement::from_sql_and_values( + backend, + "SELECT lease_cid, lease_expiry, state FROM renewal_lease WHERE original_canonical = $1 AND renewer_epoch = $2", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::BigInt(Some(renewer_epoch as i64)), + ], + )) + .await?; + for old in old_leases { + let old_bytes: Vec = old.try_get("", "lease_cid")?; + let old_id = Hash::try_from(old_bytes).map_err(DbErr::from)?; + let old_state: String = old.try_get("", "state")?; + if old_state == "revoked" || is_revoked_chain(tx, &old_id).await? { + continue; + } + let old_expiry: OffsetDateTime = old.try_get("", "lease_expiry")?; + if old_expiry.unix_timestamp() <= now { + if old_state == "active" { + tx.execute(Statement::from_sql_and_values( + backend, + "UPDATE renewal_lease SET state = 'expired' WHERE original_canonical = $1 AND lease_cid = $2", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(old_id)))), + ], + )).await?; + } + } else if old_state == "active" { + return Err(RenewalStoreError::LeaseUnavailable); + } else if old_state != "expired" { + return Err(RenewalStoreError::InvalidInput); + } + } + tx.execute(Statement::from_sql_and_values( + backend, + "INSERT INTO renewal_lease (original_canonical, lease_cid, state, generation, grantor, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key) VALUES ($1, $2, 'active', 1, $3, $4, $5, $6, $7, $8, $9)", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease_hash)))), + Value::String(Some(Box::new(lease.delegator.clone()))), + Value::String(Some(Box::new(renewer_did))), + Value::BigInt(Some(renewer_epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(lease_expiry.unix_timestamp())?))), + Value::TimeDateTime(Some(Box::new(db_datetime(original_expiry.unix_timestamp())?))), + Value::BigInt(Some(period)), + Value::Bytes(Some(Box::new(audience_public_key.to_vec()))), + ], + )) + .await?; + Ok(()) +} + +pub(crate) async fn issue_stamp( + conn: &C, + request: StampIssue, + secret: &StaticSecret, + identity: &RenewalNodeIdentity, +) -> Result { + issue_stamp_at( + conn, + request, + secret, + identity, + OffsetDateTime::now_utc().unix_timestamp(), + ) + .await +} + +async fn issue_stamp_at( + conn: &C, + request: StampIssue, + secret: &StaticSecret, + identity: &RenewalNodeIdentity, + now: i64, +) -> Result { + let (_, original) = canonical_cid(&request.original_cid)?; + let tx = crate::db::begin_write_transaction(conn, None).await?; + let result = async { + lock_original(&tx, &original).await?; + if request.replay_expires_at <= now { + return Err(RenewalStoreError::InvalidInput); + } + if let Err(error) = tx + .execute(Statement::from_sql_and_values( + tx.get_database_backend(), + "INSERT INTO invocation_replay (content_hash, expires_at) VALUES ($1, $2)", + [ + Value::Bytes(Some(Box::new(Vec::::from(request.replay_hash)))), + Value::TimeDateTime(Some(Box::new(db_datetime(request.replay_expires_at)?))), + ], + )) + .await + { + if is_unique_violation(&error) { + return Err(RenewalStoreError::Replay); + } + return Err(error.into()); + } + authorize_renewal_invocation_at( + &tx, + &request.invocation, + &request.original_cid, + identity, + now, + ) + .await?; + issue_stamp_in_transaction(&tx, request, secret, identity, now).await + } + .await; + match result { + Ok(record) => { + tx.commit().await?; + Ok(record) + } + Err(error) => { + tx.rollback().await?; + Err(error) + } + } +} + +async fn issue_stamp_in_transaction( + tx: &C, + request: StampIssue, + secret: &StaticSecret, + identity: &RenewalNodeIdentity, + now: i64, +) -> Result { + let (original_cid, original) = canonical_cid(&request.original_cid)?; + let (lease_cid, lease) = canonical_cid(&request.lease_cid)?; + let (stored_renewer_did, stored_epoch, _) = + current_renewal_identity_from_seed(tx, secret.derive_key(RENEWER_KEY_CONTEXT)).await?; + if stored_renewer_did != identity.renewer_did || stored_epoch != identity.epoch { + return Err(RenewalStoreError::IdentityEpoch); + } + let backend = tx.get_database_backend(); + let lease_row = tx.query_one(Statement::from_sql_and_values( + backend, + "SELECT state, generation, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key FROM renewal_lease WHERE original_canonical = $1 AND lease_cid = $2", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + ], + )).await?.ok_or(RenewalStoreError::LeaseUnavailable)?; + let state: String = lease_row.try_get("", "state")?; + let generation: i64 = lease_row.try_get("", "generation")?; + if generation <= 0 { + return Err(RenewalStoreError::InvalidInput); + } + let renewer_did: String = lease_row.try_get("", "renewer_did")?; + let renewer_epoch: i64 = lease_row.try_get("", "renewer_epoch")?; + let lease_expiry: OffsetDateTime = lease_row.try_get("", "lease_expiry")?; + let original_expiry: OffsetDateTime = lease_row.try_get("", "original_expiry")?; + let period: i64 = lease_row.try_get("", "period_seconds")?; + let audience: Vec = lease_row.try_get("", "audience_public_key")?; + let current_epoch = identity.epoch; + let current_epoch_i64 = + i64::try_from(current_epoch).map_err(|_| RenewalStoreError::InvalidInput)?; + if state != "active" || now >= lease_expiry.unix_timestamp() { + return Err(RenewalStoreError::LeaseUnavailable); + } + if renewer_did != identity.renewer_did || renewer_epoch != current_epoch_i64 { + return Err(RenewalStoreError::IdentityEpoch); + } + let snapshot = load_renewal_snapshot( + tx, + &[original, lease], + &identity.stable_node_did, + &identity.renewer_did, + current_epoch, + now, + ) + .await?; + if snapshot.delegation(&original).and_then(|row| row.expiry) != Some(original_expiry) + || snapshot.delegation(&lease).and_then(|row| row.expiry) != Some(lease_expiry) + { + return Err(RenewalStoreError::InvalidInput); + } + let original_chain = live_chain_effective_ends(&snapshot, &original, now)?; + live_chain_effective_ends(&snapshot, &lease, now)?; + let current_original_end = original_chain + .iter() + .find_map(|(id, end)| (*id == original).then_some(*end)) + .ok_or(RenewalStoreError::LeaseUnavailable)?; + let ancestor_ends = original_chain + .iter() + .filter_map(|(id, end)| (*id != original).then_some(*end)) + .collect::>(); + let previous = latest_stamp_cid(tx, &original, &lease) + .await? + .unwrap_or_else(|| lease_cid.clone()); + if now >= current_original_end { + return Err(RenewalStoreError::LeaseUnavailable); + } + let new_end = new_end(now, period, lease_expiry.unix_timestamp(), &ancestor_ends)?; + if new_end <= current_original_end { + return Err(RenewalStoreError::LeaseUnavailable); + } + let signer = RenewalSigner::from_seed( + secret.derive_key(RENEWER_KEY_CONTEXT), + identity.stable_node_did.clone(), + current_epoch, + ); + let new_end_string = unix_time(new_end)? + .format(&time::format_description::well_known::Rfc3339) + .map_err(|_| RenewalStoreError::InvalidInput)?; + let (signed_bytes, signature, cid) = signer + .sign_stamp( + &original_cid, + &lease_cid, + generation as u64, + &previous, + &new_end_string, + ) + .map_err(|_| RenewalStoreError::InvalidInput)?; + let audience_key: [u8; 32] = audience + .as_slice() + .try_into() + .map_err(|_| RenewalStoreError::InvalidInput)?; + verify_stamp_signature(&signed_bytes, &signature, &audience_key)?; + validate_stamp_end( + new_end, + now, + period, + lease_expiry.unix_timestamp(), + &ancestor_ends, + )?; + + let stamp_hash = canonical_cid(&cid)?.1; + tx.execute(Statement::from_sql_and_values( + backend, + "INSERT INTO renewal_stamp (cid, original_canonical, lease_cid, generation, previous_stamp_cid, renewer_epoch, new_end, signed_bytes, signature, replay_hash) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + [ + Value::Bytes(Some(Box::new(Vec::::from(stamp_hash)))), + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::BigInt(Some(generation)), + Value::Bytes(Some(Box::new(cid_hash_bytes(&previous)?))), + Value::BigInt(Some(current_epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(new_end)?))), + Value::Bytes(Some(Box::new(signed_bytes.clone()))), + Value::Bytes(Some(Box::new(signature.clone()))), + Value::Bytes(Some(Box::new(Vec::::from(request.replay_hash)))), + ], + )).await?; + Ok(StampRecord { + cid, + lease_cid, + new_end, + previous_stamp_cid: previous, + generation: generation as u64, + signed_bytes, + signature, + }) +} + +async fn lock_original( + tx: &C, + original: &Hash, +) -> Result<(), RenewalStoreError> { + match tx.get_database_backend() { + DatabaseBackend::Postgres => { + let bytes = crate::hash::hash(original.as_ref()); + let key = i64::from_be_bytes(bytes.as_ref()[..8].try_into().unwrap()); + tx.query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT pg_advisory_xact_lock($1)", + [Value::BigInt(Some(key))], + )) + .await?; + } + DatabaseBackend::Sqlite => { + tx.execute_unprepared("UPDATE epoch SET seq = seq WHERE 0") + .await?; + } + DatabaseBackend::MySql => return Err(RenewalStoreError::InvalidInput), + } + Ok(()) +} + +async fn is_revoked_chain( + db: &C, + id: &Hash, +) -> Result { + let row = db.query_one(Statement::from_sql_and_values( + db.get_database_backend(), + "WITH RECURSIVE chain(id) AS (SELECT $1 UNION SELECT p.parent FROM parent_delegation p JOIN chain c ON p.child = c.id) SELECT 1 AS revoked FROM chain c JOIN revocation r ON r.revoked = c.id LIMIT 1", + [Value::Bytes(Some(Box::new(Vec::::from(*id))))], + )).await?; + Ok(row.is_some()) +} + +fn is_unique_violation(error: &DbErr) -> bool { + matches!(error, DbErr::Exec(sea_orm::RuntimeErr::SqlxError(err)) if err.to_string().contains("unique")) + || error.to_string().contains("UNIQUE constraint failed") +} + +async fn latest_stamp_cid( + db: &C, + original: &Hash, + lease: &Hash, +) -> Result, RenewalStoreError> { + let row = db.query_one(Statement::from_sql_and_values( + db.get_database_backend(), + "SELECT cid FROM renewal_stamp s WHERE s.original_canonical = $1 AND s.lease_cid = $2 AND NOT EXISTS (SELECT 1 FROM renewal_stamp n WHERE n.original_canonical = s.original_canonical AND n.lease_cid = s.lease_cid AND n.previous_stamp_cid = s.cid)", + [ + Value::Bytes(Some(Box::new(Vec::::from(*original)))), + Value::Bytes(Some(Box::new(Vec::::from(*lease)))), + ], + )).await?; + row.map(|r| { + let bytes: Vec = r.try_get("", "cid")?; + let hash = Hash::try_from(bytes).map_err(DbErr::from)?; + Ok::(hash.to_cid(0x55).to_string()) + }) + .transpose() + .map_err(Into::into) +} + +pub async fn effective_end( + db: &C, + original_cid: &str, + lease_cid: &str, + verifier_now: i64, + identity: &RenewalNodeIdentity, +) -> Result, RenewalStoreError> { + let (_, original) = canonical_cid(original_cid)?; + let (_, lease) = canonical_cid(lease_cid)?; + let snapshot = load_renewal_snapshot( + db, + &[original, lease], + &identity.stable_node_did, + &identity.renewer_did, + identity.epoch, + verifier_now, + ) + .await?; + let original_chain = match live_chain_effective_ends(&snapshot, &original, verifier_now) { + Ok(chain) => chain, + Err(RenewalStoreError::LeaseUnavailable) => return Ok(None), + Err(error) => return Err(error), + }; + live_chain_effective_ends(&snapshot, &lease, verifier_now)?; + Ok(original_chain + .iter() + .find_map(|(id, end)| (*id == original).then_some(*end))) +} + +async fn status_authorized( + db: &C, + invoker: &str, + proofs: &[Cid], + target_id: Hash, +) -> Result { + use crate::models::revocation::{ControlAction, ControlProofDecision}; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let Some(target) = crate::models::revocation::target_for_cid(db, target_id).await? else { + return Ok(false); + }; + let abilities = crate::models::abilities::Entity::find() + .filter(crate::models::abilities::Column::Delegation.eq(target.id)) + .all(db) + .await?; + Ok( + match crate::models::revocation::control_proof_decision( + db, + invoker, + proofs, + ControlAction::Status, + &target_id, + ) + .await? + { + ControlProofDecision::DirectSigner(principal) => { + ControlAction::Status.party_may_act(&target, &principal) + || abilities.iter().any(|ability| { + ability.resource.space().is_some_and(|space| { + tinycloud_auth::identity::did_principal_matches( + space.did().as_str(), + &principal, + ) + }) + }) + } + ControlProofDecision::Scoped(control) => { + control.authorizes_target(&target_id, &target, &abilities) + } + ControlProofDecision::Ungranted | ControlProofDecision::Denied => false, + }, + ) +} + +/// Read-only, non-authoritative history guarded by per-delegation status rules. +pub async fn renewal_history( + db: &C, + original_cid: &str, + invoker: &str, + proofs: &[Cid], +) -> Result, RenewalStoreError> { + let original_id = Cid::from_str(original_cid) + .map(Hash::from) + .map_err(|_| RenewalStoreError::InvalidInput)?; + if !status_authorized(db, invoker, proofs, original_id).await? { + return Err(RenewalStoreError::Unauthorized); + } + let rows = db.query_all(Statement::from_sql_and_values( + db.get_database_backend(), + "SELECT s.cid, s.lease_cid, s.generation, s.previous_stamp_cid, s.new_end, s.signed_bytes, s.signature FROM renewal_stamp s WHERE s.original_canonical = $1 ORDER BY s.lease_cid, s.generation", + [Value::Bytes(Some(Box::new(Vec::::from(original_id))))], + )).await?; + let mut history = Vec::with_capacity(rows.len()); + for row in rows { + let cid = Hash::try_from(row.try_get::>("", "cid")?).map_err(DbErr::from)?; + let lease = + Hash::try_from(row.try_get::>("", "lease_cid")?).map_err(DbErr::from)?; + if !status_authorized(db, invoker, proofs, lease).await? { + continue; + } + let previous = Hash::try_from(row.try_get::>("", "previous_stamp_cid")?) + .map_err(DbErr::from)?; + let new_end: OffsetDateTime = row.try_get("", "new_end")?; + history.push(StampRecord { + cid: cid.to_cid(0x55).to_string(), + lease_cid: lease.to_cid(0x55).to_string(), + new_end: new_end.unix_timestamp(), + previous_stamp_cid: previous.to_cid(0x55).to_string(), + generation: row.try_get::("", "generation")? as u64, + signed_bytes: row.try_get("", "signed_bytes")?, + signature: row.try_get("", "signature")?, + }); + } + Ok(history) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::hash; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use sea_orm::sea_query::Value; + use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseBackend, Statement}; + use sea_orm_migration::MigratorTrait; + use std::collections::BTreeMap; + use time::OffsetDateTime; + use tinycloud_auth::{ + authorization::TinyCloudDelegation, + resolver::DID_METHODS, + siwe_recap::Ability, + ssi::{ + claims::jwt::NumericDate, + jwk::{Algorithm, JWK}, + ucan::Payload, + }, + ucan_capabilities_object::Capabilities, + }; + + fn fixed_test_ed25519_jwk(seed: [u8; 32]) -> JWK { + let signer = ed25519_dalek::SigningKey::from_bytes(&seed); + let x = URL_SAFE_NO_PAD.encode(signer.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed); + + serde_json::from_value(serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "x": x, + "d": d, + })) + .unwrap() + } + + fn signed_test_renewal_lease( + audience: &str, + original_cid: &str, + not_before: i64, + expiration: i64, + ) -> String { + let owner_jwk = fixed_test_ed25519_jwk([0x11; 32]); + let owner_did = DID_METHODS.generate(&owner_jwk, "key").unwrap().to_string(); + let owner_method = format!("{owner_did}#{}", owner_did.trim_start_matches("did:key:")); + let mut capabilities = Capabilities::::new(); + capabilities.with_action( + format!("urn:cid:{original_cid}").parse().unwrap(), + "tinycloud.delegation/renew".parse::().unwrap(), + [BTreeMap::::new()], + ); + Payload:: { + issuer: owner_method.parse().unwrap(), + audience: audience.parse().unwrap(), + not_before: Some(NumericDate::try_from_seconds(not_before as f64).unwrap()), + expiration: NumericDate::try_from_seconds(expiration as f64).unwrap(), + nonce: Some("epoch-rotation-renewal-lease".to_owned()), + facts: None, + proof: Vec::new(), + attenuation: capabilities, + } + .sign(Algorithm::EdDSA, &owner_jwk) + .unwrap() + .encode() + .unwrap() + } + + #[test] + fn signed_period_and_ancestor_bounds_are_enforced() { + assert_eq!( + period_seconds(100, Some(0), None, OriginalFormat::Ucan), + Ok(100) + ); + assert_eq!( + period_seconds(100, None, Some(0), OriginalFormat::Ucan), + Err(RenewalError::MissingAnchor) + ); + assert_eq!(new_end(100, 1000, 2000, &[500]), Ok(500)); + assert_eq!(validate_stamp_end(500, 600, 1000, 2000, &[700]), Ok(())); + assert_eq!(validate_stamp_end(601, 600, 1000, 2000, &[700]), Ok(())); + } + + #[test] + fn stamp_signature_is_verified_with_lease_audience_key() { + let stable_node_did = renewal_identity_from_seed([8; 32]).0; + let signer = RenewalSigner::from_seed([7; 32], stable_node_did.clone(), 1); + assert_ne!(renewal_identity_from_seed([7; 32]).0, stable_node_did); + let (bytes, signature, _) = signer + .sign_stamp( + "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4", + "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq", + 0, + "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq", + "2026-11-11T00:00:00Z", + ) + .unwrap(); + assert_eq!( + verify_stamp_signature(&bytes, &signature, &signer.public_key()), + Ok(()) + ); + let node_identity_key = RenewalSigner::from_seed([8; 32], stable_node_did.clone(), 1); + assert_eq!(renewal_identity_from_seed([8; 32]).0, stable_node_did); + assert_eq!( + verify_stamp_signature(&bytes, &signature, &node_identity_key.public_key()), + Err(RenewalError::InvalidSignature) + ); + } + + #[test] + fn snapshot_stamp_binds_registered_chain_and_bounds() { + let original = "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4"; + let lease = "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq"; + let signer = RenewalSigner::from_seed([7; 32], "did:key:stable-node".to_owned(), 1); + let (bytes, signature, stamp) = signer + .sign_stamp(original, lease, 0, lease, "1970-01-01T00:33:20Z") + .unwrap(); + verify_snapshot_stamp(SnapshotStampVerification { + signed_bytes: &bytes, + signature_bytes: &signature, + expected_stamp_cid: &stamp, + expected_original_cid: original, + expected_lease_cid: lease, + generation: 0, + previous_stamp_cid: lease, + new_end: 2000, + node_did: &signer.node_did, + renewer_epoch: signer.renewer_epoch, + audience_public_key: &signer.public_key(), + verifier_now: 1000, + period: 2000, + lease_expiry: 4000, + ancestor_ends: &[3000], + }) + .unwrap(); + assert!(matches!( + verify_snapshot_stamp(SnapshotStampVerification { + signed_bytes: &bytes, + signature_bytes: &signature, + expected_stamp_cid: &stamp, + expected_original_cid: original, + expected_lease_cid: lease, + generation: 0, + previous_stamp_cid: lease, + new_end: 2000, + node_did: "did:key:wrong-node", + renewer_epoch: signer.renewer_epoch, + audience_public_key: &signer.public_key(), + verifier_now: 1000, + period: 2000, + lease_expiry: 4000, + ancestor_ends: &[3000], + }), + Err(RenewalStoreError::InvalidInput) + )); + } + + #[test] + fn snapshot_chain_allows_expired_predecessor_with_active_tip() { + let original = "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4"; + let lease = "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq"; + let signer = RenewalSigner::from_seed([7; 32], "did:key:stable-node".to_owned(), 1); + let (old_bytes, old_signature, old_cid) = signer + .sign_stamp(original, lease, 0, lease, "1970-01-01T00:16:40Z") + .unwrap(); + let verify = |bytes: &[u8], signature: &[u8], cid: &str, previous: &str, end| { + verify_snapshot_stamp(SnapshotStampVerification { + signed_bytes: bytes, + signature_bytes: signature, + expected_stamp_cid: cid, + expected_original_cid: original, + expected_lease_cid: lease, + generation: 0, + previous_stamp_cid: previous, + new_end: end, + node_did: &signer.node_did, + renewer_epoch: signer.renewer_epoch, + audience_public_key: &signer.public_key(), + verifier_now: 2_000, + period: 5_000, + lease_expiry: 9_000, + ancestor_ends: &[8_000], + }) + }; + verify(&old_bytes, &old_signature, &old_cid, lease, 1_000).unwrap(); + + let (tip_bytes, tip_signature, tip_cid) = signer + .sign_stamp(original, lease, 0, &old_cid, "1970-01-01T00:50:00Z") + .unwrap(); + verify(&tip_bytes, &tip_signature, &tip_cid, &old_cid, 3_000).unwrap(); + // The tip ends after the verifier time. + } + + #[test] + fn renewer_identity_is_deterministic_and_epoch_is_supplied_by_state() { + let first = StaticSecret::new(vec![1; 32]).unwrap(); + let same = StaticSecret::new(vec![1; 32]).unwrap(); + let rotated = StaticSecret::new(vec![2; 32]).unwrap(); + assert_eq!(renewal_identity(&first), renewal_identity(&same)); + assert_ne!(renewal_identity(&first).0, renewal_identity(&rotated).0); + assert_eq!(renewal_identity(&first).1, 1); + assert_eq!(renewal_identity_at_epoch(&rotated, 7).1, 7); + assert!(is_renewer_did(&first, &renewal_identity(&same).0)); + } + + #[tokio::test] + async fn persisted_renewer_epoch_increments_only_when_identity_changes() { + let db = Database::connect("sqlite::memory:").await.unwrap(); + db.execute_unprepared("CREATE TABLE epoch (seq BIGINT NOT NULL)") + .await + .unwrap(); + db.execute_unprepared( + "CREATE TABLE renewal_identity (id INTEGER PRIMARY KEY, renewer_did TEXT, epoch BIGINT NOT NULL)", + ) + .await + .unwrap(); + let first_seed = [1; 32]; + let second_seed = [2; 32]; + + let (first_did, first_epoch, _) = + initialize_renewal_identity(&db, first_seed).await.unwrap(); + assert_eq!(first_epoch, 1); + assert_eq!( + initialize_renewal_identity(&db, first_seed) + .await + .unwrap() + .1, + 1 + ); + let (second_did, second_epoch, _) = + initialize_renewal_identity(&db, second_seed).await.unwrap(); + assert_ne!(first_did, second_did); + assert_eq!(second_epoch, 2); + assert!(matches!( + current_renewal_identity_from_seed(&db, first_seed).await, + Err(RenewalStoreError::IdentityEpoch) + )); + assert_eq!( + initialize_renewal_identity(&db, first_seed) + .await + .unwrap() + .1, + 3 + ); + } + + #[test] + fn renew_capability_classifier_requires_exact_canonical_resource() { + let original = "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4"; + assert!(is_renewal_capability( + "tinycloud.delegation/renew", + &format!("urn:cid:{original}"), + original + )); + assert!(!is_renewal_capability( + "tinycloud.delegation/revoke", + &format!("urn:cid:{original}"), + original + )); + assert!(!is_renewal_capability( + "tinycloud.delegation/renew", + &format!("urn:cid:{original}/child"), + original + )); + } + + #[test] + fn renewal_registration_target_comes_from_signed_lease() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../tinycloud-node-server/tests/fixtures/renewal-lease-v1.json" + )) + .unwrap(); + let lease = crate::events::Delegation::from_header_ser::< + tinycloud_auth::authorization::TinyCloudDelegation, + >(fixture["leaseToken"].as_str().unwrap()) + .unwrap(); + assert_eq!( + lease_original_from_signed(&lease).unwrap().as_deref(), + Some("bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4") + ); + } + + #[tokio::test] + async fn sqlite_issue_caps_to_ancestor_and_replay_is_atomic() { + let db = Database::connect(ConnectOptions::new("sqlite::memory:".to_owned())) + .await + .unwrap(); + crate::migrations::Migrator::up(&db, None).await.unwrap(); + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../tinycloud-node-server/tests/fixtures/renewal-lease-v1.json" + )) + .unwrap(); + let original_serialization = fixture["originalToken"] + .as_str() + .unwrap() + .as_bytes() + .to_vec(); + let lease_serialization = fixture["leaseToken"].as_str().unwrap().as_bytes().to_vec(); + let original_info = signed_delegation_info(&original_serialization).unwrap(); + let lease_info = signed_delegation_info(&lease_serialization).unwrap(); + let secret = StaticSecret::new(vec![0x42; 32]).unwrap(); + let (renewer_did, renewer_epoch, audience) = + initialize_renewal_identity(&db, secret.derive_key(RENEWER_KEY_CONTEXT)) + .await + .unwrap(); + let identity = RenewalNodeIdentity { + stable_node_did: secret.node_did(), + renewer_did: renewer_did.clone(), + epoch: renewer_epoch, + }; + let original_cid = "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4"; + let lease_cid = "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq"; + let original = canonical_cid(original_cid).unwrap().1; + let lease = canonical_cid(lease_cid).unwrap().1; + let ancestor = hash(b"renewal ancestor"); + let original_expiry = original_info.expiry.unwrap().unix_timestamp(); + let lease_expiry = lease_info.expiry.unwrap().unix_timestamp(); + let period = period_seconds( + original_expiry, + original_info.not_before.map(|value| value.unix_timestamp()), + original_info.issued_at.map(|value| value.unix_timestamp()), + OriginalFormat::Ucan, + ) + .unwrap(); + let now = original_expiry - 4_000; + let end = now + 8_000; + let grantor = original_info.delegator.clone(); + let tx = crate::db::begin_write_transaction(&db, None).await.unwrap(); + for actor in [ + grantor.clone(), + renewer_did.clone(), + "did:key:child".to_owned(), + original_info.delegate.clone(), + ] { + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO actor (id) VALUES ($1)", + [Value::String(Some(Box::new(actor)))], + )) + .await + .unwrap(); + } + let insert_delegation = |id: Hash, + delegator: String, + delegatee: String, + expiry: i64, + serialization: Vec| { + Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO delegation (id, delegator, delegatee, expiry, issued_at, not_before, facts, serialization, serialization_format) VALUES ($1,$2,$3,$4,NULL,NULL,NULL,$5,0)", + [ + Value::Bytes(Some(Box::new(Vec::::from(id)))), + Value::String(Some(Box::new(delegator))), + Value::String(Some(Box::new(delegatee))), + Value::TimeDateTime(Some(Box::new(db_datetime(expiry).unwrap()))), + Value::Bytes(Some(Box::new(serialization))), + ], + ) + }; + tx.execute(insert_delegation( + ancestor, + grantor.clone(), + "did:key:child".to_owned(), + end, + vec![1], + )) + .await + .unwrap(); + tx.execute(insert_delegation( + original, + grantor.clone(), + original_info.delegate.clone(), + original_expiry, + original_serialization.clone(), + )) + .await + .unwrap(); + tx.execute(insert_delegation( + lease, + grantor.clone(), + lease_info.delegate.clone(), + lease_expiry, + lease_serialization.clone(), + )) + .await + .unwrap(); + for id in [ancestor, original, lease] { + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO delegation_cid_alias (id, canonical, encrypted) VALUES ($1,$1,FALSE)", + [Value::Bytes(Some(Box::new(Vec::::from(id))))], + )) + .await + .unwrap(); + } + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO parent_delegation (parent, child) VALUES ($1, $2)", + [ + Value::Bytes(Some(Box::new(Vec::::from(ancestor)))), + Value::Bytes(Some(Box::new(Vec::::from(original)))), + ], + )) + .await + .unwrap(); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO ability (delegation, resource, ability, caveats) VALUES ($1,$2,$3,$4)", + [ + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::String(Some(Box::new(format!("urn:cid:{original_cid}")))), + Value::String(Some(Box::new("tinycloud.delegation/renew".to_owned()))), + Value::Json(Some(Box::new(serde_json::json!({})))), + ], + )) + .await + .unwrap(); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO renewal_lease (original_canonical, lease_cid, state, generation, grantor, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key) VALUES ($1,$2,'active',1,$3,$4,$5,$6,$7,$8,$9)", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::String(Some(Box::new(grantor.to_owned()))), + Value::String(Some(Box::new(renewer_did.clone()))), + Value::BigInt(Some(renewer_epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(lease_expiry).unwrap()))), + Value::TimeDateTime(Some(Box::new(db_datetime(original_expiry).unwrap()))), + Value::BigInt(Some(period)), + Value::Bytes(Some(Box::new(audience.to_vec()))), + ], + )).await.unwrap(); + let stored_audience = tx + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "SELECT audience_public_key FROM renewal_lease WHERE original_canonical = $1", + [Value::Bytes(Some(Box::new(Vec::::from(original))))], + )) + .await + .unwrap() + .unwrap() + .try_get::>("", "audience_public_key") + .unwrap(); + assert_eq!(stored_audience, audience); + assert_eq!( + RenewalSigner::from_seed( + secret.derive_key(RENEWER_KEY_CONTEXT), + secret.node_did(), + renewer_epoch, + ) + .public_key(), + audience + ); + tx.commit().await.unwrap(); + + let invocation_token = tinycloud_auth::authorization::TinyCloudInvocation::decode( + fixture["originalToken"].as_str().unwrap(), + ) + .unwrap(); + let mut request = StampIssue { + invocation: crate::util::InvocationInfo::try_from(invocation_token).unwrap(), + original_cid: original_cid.to_owned(), + lease_cid: lease_cid.to_owned(), + replay_hash: hash(b"request replay"), + replay_expires_at: now + 60, + }; + request.invocation.invoker = renewer_did.clone(); + request.invocation.parents = vec![lease_cid.parse().unwrap()]; + request.invocation.capabilities = vec![crate::util::Capability { + resource: format!("urn:cid:{original_cid}").parse().unwrap(), + ability: "tinycloud.delegation/renew".to_owned().try_into().unwrap(), + caveats: Default::default(), + }]; + + let mut wrong_caller = request.invocation.clone(); + wrong_caller.invoker = "did:key:attacker".to_owned(); + assert!(matches!( + authorize_renewal_invocation_at(&db, &wrong_caller, original_cid, &identity, now).await, + Err(RenewalStoreError::Unauthorized) + )); + let mut extra_parent = request.invocation.clone(); + extra_parent.parents.push(original_cid.parse().unwrap()); + assert!(matches!( + authorize_renewal_invocation_at(&db, &extra_parent, original_cid, &identity, now).await, + Err(RenewalStoreError::Unauthorized) + )); + authorize_renewal_invocation_at(&db, &request.invocation, original_cid, &identity, now) + .await + .unwrap(); + let issued = issue_stamp_at(&db, request.clone(), &secret, &identity, now) + .await + .unwrap(); + assert!(issued.new_end <= end); + assert!(issued.new_end > original_expiry); + assert!(matches!( + issue_stamp_at(&db, request.clone(), &secret, &identity, now).await, + Err(RenewalStoreError::Replay) + )); + + assert_eq!( + effective_end(&db, original_cid, lease_cid, now, &identity) + .await + .unwrap(), + Some(issued.new_end) + ); + let next_seed = [8; 32]; + let (next_did, next_epoch, _) = initialize_renewal_identity(&db, next_seed).await.unwrap(); + assert_eq!(next_epoch, renewer_epoch + 1); + let next_identity = RenewalNodeIdentity { + stable_node_did: secret.node_did(), + renewer_did: next_did.clone(), + epoch: next_epoch, + }; + let mut stale_process_request = request.clone(); + stale_process_request.replay_hash = hash(b"stale process identity"); + stale_process_request.replay_expires_at = now + 120; + assert!(matches!( + issue_stamp_at(&db, stale_process_request, &secret, &identity, now + 60,).await, + Err(RenewalStoreError::IdentityEpoch) + )); + let next_snapshot = load_renewal_snapshot( + &db, + &[original, lease], + &secret.node_did(), + &next_did, + next_epoch, + now, + ) + .await + .unwrap(); + assert_eq!( + next_snapshot.effective_expiry(&original), + Some(original_expiry) + ); + assert_eq!( + effective_end(&db, original_cid, lease_cid, now, &next_identity) + .await + .unwrap(), + Some(original_expiry) + ); + assert!( + lease_for_original_at(&db, original_cid, now, &next_identity) + .await + .unwrap() + .is_none() + ); + assert!(issue_stamp_at(&db, request, &secret, &next_identity, now) + .await + .is_err()); + + let next_not_before = now - 100; + let next_expiry = next_not_before + MAX_LEASE_SECONDS; + let next_token = + signed_test_renewal_lease(&next_did, original_cid, next_not_before, next_expiry); + let next_event = + crate::events::Delegation::from_header_ser::(&next_token).unwrap(); + let next_lease = crate::hash::hash(next_event.serialized_bytes()); + let next_info = signed_delegation_info(next_event.serialized_bytes()).unwrap(); + assert_eq!(next_info.delegator, grantor); + assert_eq!(next_info.delegate, next_did); + assert_eq!(next_info.expiry.unwrap().unix_timestamp(), next_expiry); + let tx = crate::db::begin_write_transaction(&db, None).await.unwrap(); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO actor (id) VALUES ($1)", + [Value::String(Some(Box::new(next_did.clone())))], + )) + .await + .unwrap(); + tx.execute(insert_delegation( + next_lease, + grantor.clone(), + next_did.clone(), + next_expiry, + next_token.as_bytes().to_vec(), + )) + .await + .unwrap(); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO delegation_cid_alias (id, canonical, encrypted) VALUES ($1,$1,FALSE)", + [Value::Bytes(Some(Box::new(Vec::::from(next_lease))))], + )) + .await + .unwrap(); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO ability (delegation, resource, ability, caveats) VALUES ($1,$2,$3,$4)", + [ + Value::Bytes(Some(Box::new(Vec::::from(next_lease)))), + Value::String(Some(Box::new(format!("urn:cid:{original_cid}")))), + Value::String(Some(Box::new("tinycloud.delegation/renew".to_owned()))), + Value::Json(Some(Box::new(serde_json::json!({})))), + ], + )) + .await + .unwrap(); + register_lease_at( + &tx, + &next_event, + next_seed, + next_epoch, + &secret.node_did(), + None, + now, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + let expected_next_lease_cid = next_lease.to_cid(0x55).to_string(); + assert_eq!( + lease_for_original_at(&db, original_cid, now, &next_identity) + .await + .unwrap() + .unwrap() + .lease_cid, + expected_next_lease_cid + ); + let lease_count: i64 = db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "SELECT COUNT(*) AS count FROM renewal_lease WHERE original_canonical = $1", + [Value::Bytes(Some(Box::new(Vec::::from(original))))], + )) + .await + .unwrap() + .unwrap() + .try_get("", "count") + .unwrap(); + assert_eq!(lease_count, 2); + for epoch in [1_i64, next_epoch as i64] { + let rows: i64 = db + .query_one(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "SELECT COUNT(*) AS count FROM renewal_lease WHERE original_canonical = $1 AND renewer_epoch = $2", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::BigInt(Some(epoch)), + ], + )) + .await + .unwrap() + .unwrap() + .try_get("", "count") + .unwrap(); + assert_eq!(rows, 1); + } + let replay_count: i64 = db + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT COUNT(*) AS count FROM invocation_replay", + )) + .await + .unwrap() + .unwrap() + .try_get("", "count") + .unwrap(); + let stamp_count: i64 = db + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT COUNT(*) AS count FROM renewal_stamp", + )) + .await + .unwrap() + .unwrap() + .try_get("", "count") + .unwrap(); + assert_eq!((replay_count, stamp_count), (1, 1)); + } + + #[tokio::test] + async fn lease_registration_requires_a_live_original_or_valid_renewal() { + let db = Database::connect(ConnectOptions::new("sqlite::memory:".to_owned())) + .await + .unwrap(); + crate::migrations::Migrator::up(&db, None).await.unwrap(); + let secret = StaticSecret::new(vec![13; 32]).unwrap(); + let renewal_seed = secret.derive_key(b"tinycloud/node/renewer/v1"); + let (renewer_did, renewer_epoch, audience) = renewal_identity_from_seed(renewal_seed); + let original_cid = "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4"; + let lease_cid = "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq"; + let original = canonical_cid(original_cid).unwrap().1; + let lease = canonical_cid(lease_cid).unwrap().1; + let grantor = "did:key:renewal-owner"; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let original_expiry = now - 100; + let lease_expiry = now + 1_000; + let tx = crate::db::begin_write_transaction(&db, None).await.unwrap(); + for actor in [ + grantor.to_owned(), + renewer_did.clone(), + "did:key:holder".to_owned(), + ] { + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO actor (id) VALUES ($1)", + [Value::String(Some(Box::new(actor)))], + )) + .await + .unwrap(); + } + for (id, delegatee, expiry) in [ + (original, "did:key:holder", original_expiry), + (lease, renewer_did.as_str(), lease_expiry), + ] { + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO delegation (id, delegator, delegatee, expiry, issued_at, not_before, facts, serialization, serialization_format) VALUES ($1,$2,$3,$4,NULL,NULL,NULL,$5,0)", + [ + Value::Bytes(Some(Box::new(Vec::::from(id)))), + Value::String(Some(Box::new(grantor.to_owned()))), + Value::String(Some(Box::new(delegatee.to_owned()))), + Value::TimeDateTime(Some(Box::new(db_datetime(expiry).unwrap()))), + Value::Bytes(Some(Box::new(Vec::::from(id)))), + ], + )) + .await + .unwrap(); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO delegation_cid_alias (id, canonical, encrypted) VALUES ($1,$1,FALSE)", + [Value::Bytes(Some(Box::new(Vec::::from(id))))], + )) + .await + .unwrap(); + } + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO renewal_lease (original_canonical, lease_cid, state, generation, grantor, renewer_did, renewer_epoch, lease_expiry, original_expiry, period_seconds, audience_public_key) VALUES ($1,$2,'active',1,$3,$4,$5,$6,$7,10000,$8)", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::String(Some(Box::new(grantor.to_owned()))), + Value::String(Some(Box::new(renewer_did.clone()))), + Value::BigInt(Some(renewer_epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(lease_expiry).unwrap()))), + Value::TimeDateTime(Some(Box::new(db_datetime(original_expiry).unwrap()))), + Value::Bytes(Some(Box::new(audience.to_vec()))), + ], + )) + .await + .unwrap(); + + let roots = [original, lease]; + let snapshot = load_renewal_snapshot( + &tx, + &roots, + &secret.node_did(), + &renewer_did, + renewer_epoch, + now, + ) + .await + .unwrap(); + assert!(matches!( + live_chain_effective_ends(&snapshot, &original, now), + Err(RenewalStoreError::LeaseUnavailable) + )); + + let renewed_end = now + 500; + let renewed_end_text = OffsetDateTime::from_unix_timestamp(renewed_end) + .unwrap() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + let signer = RenewalSigner::from_seed(renewal_seed, secret.node_did(), renewer_epoch); + let (signed_bytes, signature, _) = signer + .sign_stamp(original_cid, lease_cid, 1, lease_cid, &renewed_end_text) + .unwrap(); + let forged_signature = + RenewalSigner::from_seed([0xa5; 32], secret.node_did(), renewer_epoch) + .sign_stamp(original_cid, lease_cid, 1, lease_cid, &renewed_end_text) + .unwrap() + .1; + let stamp_hash = hash(&signed_bytes); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "INSERT INTO renewal_stamp (cid, original_canonical, lease_cid, generation, previous_stamp_cid, renewer_epoch, new_end, signed_bytes, signature, replay_hash) VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9)", + [ + Value::Bytes(Some(Box::new(Vec::::from(stamp_hash)))), + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::Bytes(Some(Box::new(Vec::::from(lease)))), + Value::BigInt(Some(renewer_epoch as i64)), + Value::TimeDateTime(Some(Box::new(db_datetime(renewed_end).unwrap()))), + Value::Bytes(Some(Box::new(signed_bytes.clone()))), + Value::Bytes(Some(Box::new(forged_signature))), + Value::Bytes(Some(Box::new(hash(&signed_bytes).as_ref().to_vec()))), + ], + )) + .await + .unwrap(); + let forged_snapshot = load_renewal_snapshot( + &tx, + &roots, + &secret.node_did(), + &renewer_did, + renewer_epoch, + now, + ) + .await + .unwrap(); + assert!(matches!( + live_chain_effective_ends(&forged_snapshot, &original, now), + Err(RenewalStoreError::LeaseUnavailable) + )); + tx.execute(Statement::from_sql_and_values( + DatabaseBackend::Sqlite, + "UPDATE renewal_stamp SET signature = $1 WHERE cid = $2", + [ + Value::Bytes(Some(Box::new(signature))), + Value::Bytes(Some(Box::new(Vec::::from(stamp_hash)))), + ], + )) + .await + .unwrap(); + let snapshot = load_renewal_snapshot( + &tx, + &roots, + &secret.node_did(), + &renewer_did, + renewer_epoch, + now, + ) + .await + .unwrap(); + let original_ends = live_chain_effective_ends(&snapshot, &original, now).unwrap(); + assert_eq!( + original_ends.first().copied(), + Some((original, renewed_end)) + ); + tx.rollback().await.unwrap(); + } +} diff --git a/tinycloud-core/tests/historical_cacao_revocation.rs b/tinycloud-core/tests/historical_cacao_revocation.rs index c4cbe737..2d8ecb39 100644 --- a/tinycloud-core/tests/historical_cacao_revocation.rs +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -411,6 +411,13 @@ async fn unstored_canonical_status_resolves_historical_grant() { assert_ne!(canonical, fixture.rows[0].raw_cid); let (_directory, path) = copy_database(&fixture); let db = open(&path).await; + assert_eq!( + db.delegation_effective_expiry(hash_from_cid(&canonical), 0) + .await + .map_err(|error| error.to_string()) + .unwrap(), + Some(4_102_444_800), + ); assert_eq!( db.delegation_status(hash_from_cid(&canonical), &fixture.issuer, &[]) .await @@ -418,6 +425,18 @@ async fn unstored_canonical_status_resolves_historical_grant() { .unwrap(), Some(tinycloud_core::db::DelegationStatus::Active), ); + for cid in [&canonical, &fixture.rows[0].raw_cid] { + assert!(tinycloud_core::renewal::renewal_history( + db.connection(), + cid, + &fixture.issuer, + &[], + ) + .await + .map_err(|error| error.to_string()) + .unwrap() + .is_empty()); + } } #[tokio::test] diff --git a/tinycloud-node-server/src/auth_guards.rs b/tinycloud-node-server/src/auth_guards.rs index 8c211525..8011f7c3 100644 --- a/tinycloud-node-server/src/auth_guards.rs +++ b/tinycloud-node-server/src/auth_guards.rs @@ -263,6 +263,7 @@ where .sized_body(data.len(), std::io::Cursor::new(data)) .ok(), InvocationOutcome::EncryptionDecrypt(response) => Json(response).respond_to(request), + InvocationOutcome::RenewalStamp(value) => Json(value).respond_to(request), InvocationOutcome::SpaceInfo(json) => Json(json).respond_to(request), } } @@ -280,6 +281,29 @@ mod tests { tinycloud_core::storage::memory::MemoryStore, tinycloud_core::keys::StaticSecret, >; + #[get("/renewal-stamp")] + fn renewal_stamp_response() -> InvOut>> { + InvOut(InvocationOutcome::RenewalStamp(serde_json::json!({ + "stampCid": "bafkr4iewc5ewhaccw5xh3cvxokvkehjvf76atyhvftq4i4eq7ayneimt3e" + }))) + } + + #[tokio::test] + async fn renewal_stamp_outcome_is_json() -> Result<()> { + let client = + Client::tracked(rocket::build().mount("/", routes![renewal_stamp_response])).await?; + let response = client.get("/renewal-stamp").dispatch().await; + + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.content_type(), Some(ContentType::JSON)); + assert_eq!( + serde_json::from_str::(&response.into_string().await.unwrap())?, + serde_json::json!({ + "stampCid": "bafkr4iewc5ewhaccw5xh3cvxokvkehjvf76atyhvftq4i4eq7ayneimt3e" + }) + ); + Ok(()) + } #[get("/historical/?")] async fn historical_delegation_response( diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index a2d6110d..faa3e498 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -44,6 +44,7 @@ pub mod node_control; pub mod policy_v3; pub mod prometheus; pub mod quota; +pub mod renewal; pub mod routes; pub mod runtime; pub mod share_email; @@ -761,7 +762,6 @@ pub async fn app_with_control( ); // TC-359: publish the derived public halves so an operator can read the // invitation key a dstack-keyed node actually signs with. - let node_public_keys = NodePublicKeys::derive(&key_setup); let (column_encryption, webhook_encryption) = storage_keyrings(&key_setup, &tinycloud_config.column_encryption); let hook_runtime = HookRuntime::new( @@ -863,6 +863,11 @@ pub async fn app_with_control( .with_sql_sizes(sql_sizes.clone()) .with_delegated_sql_caveats(tinycloud_config.sql.delegated_caveats_enforced) .with_named_keys(tinycloud_config.encryption_network.named_keys_v1); + let renewer_epoch = tinycloud + .renewal_identity() + .map(|identity| identity.epoch) + .ok_or_else(|| anyhow::anyhow!("node renewal identity is not initialized"))?; + let node_public_keys = NodePublicKeys::derive_at_epoch(&key_setup, renewer_epoch); tinycloud.set_read_audit_wait(Duration::from_millis( tinycloud_config.database.read_audit_wait_ms, )); @@ -1058,7 +1063,8 @@ pub async fn app_with_control( let telemetry_enabled = tinycloud_config.telemetry.enabled; let telemetry_tinycloud = tinycloud.clone(); let backfill_tinycloud = tinycloud.clone(); - + let renewal_scheduler_tinycloud = tinycloud.clone(); + let renewal_scheduler_secret = key_setup.clone(); let rocket = rocket::custom(config) .attach(AdHoc::on_liftoff("webhook-egress-refresh", { let policy = egress_policy.clone(); @@ -1089,6 +1095,40 @@ pub async fn app_with_control( ) .attach(AdHoc::config::()) .attach(tracing_fairing) + .attach(AdHoc::on_liftoff("renewal-scheduler", move |rocket| { + let tinycloud = renewal_scheduler_tinycloud.clone(); + let secret = renewal_scheduler_secret.clone(); + let shutdown = rocket.shutdown(); + Box::pin(async move { + tokio::spawn(async move { + let period = renewal::SCHEDULER_INTERVAL; + if let Err(error) = renewal::scheduler_tick(&tinycloud, &secret).await { + ::tracing::warn!( + event = "renewal_scheduler_tick_failed", + error = %error, + "internal renewal scheduler pass failed" + ); + } + let mut interval = + tokio::time::interval_at(tokio::time::Instant::now() + period, period); + tokio::pin!(shutdown); + loop { + tokio::select! { + _ = interval.tick() => { + if let Err(error) = renewal::scheduler_tick(&tinycloud, &secret).await { + ::tracing::warn!( + event = "renewal_scheduler_tick_failed", + error = %error, + "internal renewal scheduler pass failed" + ); + } + } + _ = &mut shutdown => break, + } + } + }); + }) + })) .attach(AdHoc::on_liftoff( "invocation-replay-cleanup", move |rocket| { diff --git a/tinycloud-node-server/src/renewal.rs b/tinycloud-node-server/src/renewal.rs new file mode 100644 index 00000000..2dbdef89 --- /dev/null +++ b/tinycloud-node-server/src/renewal.rs @@ -0,0 +1,370 @@ +pub use tinycloud_core::renewal::{ + new_end, period_seconds, validate_stamp_end, verify_stamp_signature, OriginalFormat, + RenewalError, RenewalSigner, RENEWER_KEY_CONTEXT, STAMP_DOMAIN, +}; + +use anyhow::{anyhow, Result}; +use tinycloud_auth::{ + authorization::{make_invocation_from_uris, InvocationOptions, TinyCloudInvocation}, + ssi::jwk::{Algorithm, Base64urlUInt, OctetParams, Params, JWK}, +}; +use tinycloud_core::{ + events::Invocation, + keys::StaticSecret, + renewal::{self, LeaseRecord, RenewalStoreError, StampIssue}, +}; + +use std::future::Future; +pub(crate) const SCHEDULER_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300); +const INVOCATION_LIFETIME_SECONDS: i64 = 300; + +fn due_for_renewal(now: i64, end: i64, period_seconds: u64) -> bool { + let horizon = now.saturating_add((period_seconds / 2).max(1).min(i64::MAX as u64) as i64); + end <= horizon +} + +fn renewal_invocation_signer(secret: &StaticSecret) -> Result<(JWK, String)> { + let keypair = secret.renewal_keypair(); + let public_key = keypair + .public() + .try_into_ed25519() + .map_err(|error| anyhow!("{error}"))?; + let mut jwk = JWK::from(Params::OKP(OctetParams { + curve: "Ed25519".to_owned(), + public_key: Base64urlUInt(public_key.to_bytes().to_vec()), + private_key: Some(Base64urlUInt( + secret.derive_key(RENEWER_KEY_CONTEXT).to_vec(), + )), + })); + jwk.algorithm = Some(Algorithm::EdDSA); + let did = secret.renewal_did(); + let fragment = did + .rsplit_once(':') + .map(|(_, fragment)| fragment) + .ok_or_else(|| anyhow!("renewal DID has no key fragment"))?; + Ok((jwk, format!("{did}#{fragment}"))) +} + +fn build_scheduled_invocation( + lease: &LeaseRecord, + signer: &JWK, + verification_method: &str, + now: i64, +) -> Result<(TinyCloudInvocation, i64)> { + let lease_cid = lease.lease_cid.parse()?; + let expiry = now + .checked_add(INVOCATION_LIFETIME_SECONDS) + .ok_or_else(|| anyhow!("renewal invocation expiry overflow"))?; + let invocation = make_invocation_from_uris( + [( + format!("urn:cid:{}", lease.original_cid).parse()?, + ["tinycloud.delegation/renew".parse()?], + )], + &lease_cid, + signer, + verification_method, + expiry as f64, + InvocationOptions { + nonce: Some(format!("urn:uuid:{}", uuid::Uuid::new_v4())), + proof: Some(vec![lease_cid]), + ..Default::default() + }, + )?; + Ok((invocation, expiry)) +} + +pub(crate) async fn scheduler_tick( + tinycloud: &crate::TinyCloud, + secret: &StaticSecret, +) -> Result<()> { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let conn = tinycloud.connection(); + let identity = tinycloud + .renewal_identity() + .ok_or_else(|| anyhow!("node renewal identity is not initialized"))?; + let leases = renewal::active_leases_for_current_epoch(conn, identity).await?; + let (signer, verification_method) = renewal_invocation_signer(secret)?; + for_each_scheduled_lease(leases, |lease| { + let tinycloud = tinycloud; + let conn = conn; + let identity = identity; + let signer = &signer; + let verification_method = &verification_method; + async move { + if lease.state != "active" || lease.renewer_did != identity.renewer_did { + return Ok(()); + } + let Some(end) = + renewal::effective_end(conn, &lease.original_cid, &lease.lease_cid, now, identity) + .await? + else { + return Ok(()); + }; + if !due_for_renewal(now, end, lease.period_seconds) { + return Ok(()); + } + let (signed, replay_expires_at) = + build_scheduled_invocation(&lease, signer, verification_method, now)?; + let encoded = signed.encode()?; + let event = Invocation::from_header_ser::(&encoded) + .map_err(|error| anyhow!("{error:?}"))?; + let replay_hash = event.content_hash(); + let issue = StampIssue { + invocation: event.0, + original_cid: lease.original_cid, + lease_cid: lease.lease_cid, + replay_hash, + replay_expires_at, + }; + tinycloud.issue_renewal_stamp(issue).await?; + Ok(()) + } + }) + .await; + Ok(()) +} + +async fn for_each_scheduled_lease(leases: Vec, mut process: F) +where + F: FnMut(LeaseRecord) -> Fut, + Fut: Future>, +{ + for lease in leases { + let lease_cid = lease.lease_cid.clone(); + if let Err(error) = process(lease).await { + if error + .downcast_ref::() + .is_some_and(|error| { + matches!( + error, + RenewalStoreError::LeaseUnavailable | RenewalStoreError::IdentityEpoch + ) + }) + { + continue; + } + ::tracing::warn!( + event = "renewal_scheduler_lease_failed", + lease_cid = %lease_cid, + error = %error, + "internal renewal scheduler failed for one lease; continuing" + ); + } + } +} + +#[cfg(test)] +mod scheduler_tests { + use super::{build_scheduled_invocation, due_for_renewal, renewal_invocation_signer}; + use tinycloud_core::{keys::StaticSecret, renewal::LeaseRecord, util::InvocationInfo}; + + #[test] + fn scheduler_builds_a_single_lease_bound_renewal_invocation() -> anyhow::Result<()> { + let secret = StaticSecret::new(vec![0x73; 32]).unwrap(); + let expected_renewer_did = secret.renewal_did(); + let (signer, verification_method) = renewal_invocation_signer(&secret)?; + let lease = LeaseRecord { + original_cid: "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4".to_owned(), + lease_cid: "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq".to_owned(), + grantor: "did:key:owner".to_owned(), + renewer_did: expected_renewer_did.clone(), + renewer_epoch: 7, + lease_expiry: 2_000, + period_seconds: 30, + state: "active".to_owned(), + generation: 1, + }; + let (signed, expiry) = + build_scheduled_invocation(&lease, &signer, &verification_method, 1_000)?; + let info = InvocationInfo::try_from(signed)?; + assert_eq!(info.invoker, expected_renewer_did); + let fragment = lease.renewer_did.rsplit_once(':').unwrap().1; + assert_eq!( + verification_method, + format!("{}#{fragment}", lease.renewer_did) + ); + assert_eq!(info.parents.len(), 1); + assert_eq!(info.parents[0].to_string(), lease.lease_cid); + assert_eq!(info.capabilities.len(), 1); + assert_eq!( + info.capabilities[0].ability.as_ref().as_ref(), + "tinycloud.delegation/renew" + ); + assert_eq!( + info.capabilities[0].resource.to_string(), + format!("urn:cid:{}", lease.original_cid) + ); + assert_eq!(expiry, 1_300); + + Ok(()) + } + + #[test] + fn scheduler_waits_until_half_of_the_signed_period_remains() { + assert!(!due_for_renewal(100, 116, 30)); + assert!(due_for_renewal(100, 115, 30)); + assert!(due_for_renewal(100, 101, 30)); + assert!(!due_for_renewal(100, 102, 1)); + assert!(due_for_renewal(100, 100, 1)); + } + #[tokio::test] + async fn scheduler_continues_after_a_lease_failure() { + let lease = |original_cid: &str| LeaseRecord { + original_cid: original_cid.to_owned(), + lease_cid: "lease".to_owned(), + grantor: "did:key:owner".to_owned(), + renewer_did: "did:key:renewer".to_owned(), + renewer_epoch: 1, + lease_expiry: 2_000, + period_seconds: 30, + state: "active".to_owned(), + generation: 1, + }; + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + super::for_each_scheduled_lease(vec![lease("bad"), lease("later")], { + let seen = std::sync::Arc::clone(&seen); + move |lease| { + let seen = std::sync::Arc::clone(&seen); + async move { + let fails = lease.original_cid == "bad"; + seen.lock().unwrap().push(lease.original_cid); + if fails { + anyhow::bail!("invalid ancestor chain"); + } + Ok(()) + } + } + }) + .await; + assert_eq!(*seen.lock().unwrap(), ["bad", "later"]); + } +} + +#[cfg(test)] +mod tests { + use super::{ + new_end, period_seconds, validate_stamp_end, verify_stamp_signature, OriginalFormat, + RenewalSigner, RENEWER_KEY_CONTEXT, STAMP_DOMAIN, + }; + use ed25519_dalek::{Signer, SigningKey}; + use tinycloud_core::keys::{Secrets, StaticSecret}; + + #[test] + fn frozen_domain_and_key_context_are_literal_contracts() { + assert_eq!(STAMP_DOMAIN, "tinycloud/node/renewal-stamp/v1"); + assert_eq!(RENEWER_KEY_CONTEXT, b"tinycloud/node/renewer/v1"); + } + + #[test] + fn period_uses_only_signed_original_anchor_and_cacao_issued_at_fallback() { + assert_eq!( + period_seconds( + 1_702_592_000, + Some(1_700_000_000), + None, + OriginalFormat::Ucan + ) + .unwrap(), + 2_592_000 + ); + assert_eq!( + period_seconds( + 1_702_592_000, + None, + Some(1_700_000_000), + OriginalFormat::Cacao + ) + .unwrap(), + 2_592_000 + ); + assert!(period_seconds( + 1_702_592_000, + None, + Some(1_700_000_000), + OriginalFormat::Ucan + ) + .is_err()); + assert!(period_seconds( + 1_700_000_000, + Some(1_700_000_000), + None, + OriginalFormat::Ucan + ) + .is_err()); + } + + #[test] + fn new_end_is_bounded_by_every_ancestor_as_well_as_lease_and_period() { + let now = 1_702_000_000; + let period = 2_592_000; + let lease_expiry = 1_731_536_000; + let ancestor_end = now + 100_000; + assert_eq!( + new_end(now, period, lease_expiry, &[ancestor_end]).unwrap(), + ancestor_end + ); + assert!(new_end(now, 0, lease_expiry, &[]).is_err()); + } + + #[test] + fn stamp_bounds_are_checked_at_creation_and_acceptance() { + let stamp_end = 1_702_100_000; + let now = 1_702_000_000; + let period = 2_592_000; + assert!( + validate_stamp_end(stamp_end, now, period, 1_731_536_000, &[1_702_200_000]).is_ok() + ); + assert!( + validate_stamp_end(stamp_end, now, period, 1_702_050_000, &[1_702_200_000]).is_err() + ); + assert!( + validate_stamp_end(stamp_end, now, period, 1_731_536_000, &[1_702_050_000]).is_err() + ); + assert!(validate_stamp_end(now + period + 1, now, period, 1_731_536_000, &[]).is_err()); + } + + #[test] + fn renewal_signer_is_the_lease_audience_key_not_the_stable_node_identity_key() { + let node = StaticSecret::new(vec![0x42; 32]).unwrap(); + let signer = RenewalSigner::from_seed(node.renewal_seed().unwrap(), node.node_did(), 1); + let (bytes, signature, _) = signer + .sign_stamp("original", "lease", 1, "lease", "2024-01-12T22:13:20Z") + .unwrap(); + assert_eq!( + signer.public_key(), + node.renewal_keypair() + .public() + .try_into_ed25519() + .unwrap() + .to_bytes() + ); + verify_stamp_signature(&bytes, &signature, &signer.public_key()).unwrap(); + let node_public = node + .node_keypair() + .public() + .try_into_ed25519() + .unwrap() + .to_bytes(); + assert!(verify_stamp_signature(&bytes, &signature, &node_public).is_err()); + } + + #[test] + fn wrong_audience_key_rejects_a_stamp_signed_by_the_renewer() { + let renewer = SigningKey::from_bytes(&[0x33; 32]); + let node = SigningKey::from_bytes(&[0x44; 32]); + let bytes = b"canonical renewal stamp bytes"; + let signature = renewer.sign(bytes); + assert!(verify_stamp_signature( + bytes, + &signature.to_bytes(), + &renewer.verifying_key().to_bytes() + ) + .is_ok()); + assert!(verify_stamp_signature( + bytes, + &signature.to_bytes(), + &node.verifying_key().to_bytes() + ) + .is_err()); + } +} diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 747b3665..fa1045ec 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -584,6 +584,17 @@ pub struct DelegationStatusRequest { pub cid: String, } +#[derive(Serialize)] +pub struct RenewalStampHistory { + pub cid: String, + pub lease_cid: String, + pub generation: u64, + pub previous_stamp_cid: String, + pub new_end: i64, + pub signed_bytes: String, + pub signature: String, +} + #[derive(Serialize)] pub struct DelegationStatusResponse { pub cid: String, @@ -592,10 +603,17 @@ pub struct DelegationStatusResponse { pub active: bool, pub revoked: bool, pub expired: bool, + pub effective_expiry: Option, + pub renewal_history: Vec, } impl DelegationStatusResponse { - fn from_status(cid: String, status: DelegationStatus) -> Self { + fn from_status( + cid: String, + status: DelegationStatus, + effective_expiry: Option, + renewal_history: Vec, + ) -> Self { let status_name = match status { DelegationStatus::Active => "active", DelegationStatus::Revoked => "revoked", @@ -609,6 +627,8 @@ impl DelegationStatusResponse { active: status == DelegationStatus::Active, revoked: status == DelegationStatus::Revoked, expired: status == DelegationStatus::Expired, + effective_expiry, + renewal_history, } } @@ -620,6 +640,8 @@ impl DelegationStatusResponse { active: false, revoked: false, expired: false, + effective_expiry: None, + renewal_history: Vec::new(), } } } @@ -667,10 +689,48 @@ pub async fn delegation_status( .await .map_err(|_| (Status::ServiceUnavailable, "Status unavailable".to_string()))? { - Some(status) => Ok(Custom( - Status::Ok, - Json(DelegationStatusResponse::from_status(request.cid, status)), - )), + Some(status) => { + let effective_expiry = tinycloud + .delegation_effective_expiry( + target, + time::OffsetDateTime::now_utc().unix_timestamp(), + ) + .await + .map_err(|_| (Status::ServiceUnavailable, "Status unavailable".to_string()))?; + let history = tinycloud_core::renewal::renewal_history( + tinycloud.connection(), + &request.cid, + &auth.invoker, + &auth.parents, + ) + .await + .map_err(|error| match error { + tinycloud_core::renewal::RenewalStoreError::Unauthorized => { + (Status::Forbidden, "Renewal history unavailable".to_string()) + } + _ => (Status::ServiceUnavailable, "Status unavailable".to_string()), + })? + .into_iter() + .map(|stamp| RenewalStampHistory { + cid: stamp.cid, + lease_cid: stamp.lease_cid, + generation: stamp.generation, + previous_stamp_cid: stamp.previous_stamp_cid, + new_end: stamp.new_end, + signed_bytes: encode_config(stamp.signed_bytes, URL_SAFE_NO_PAD), + signature: encode_config(stamp.signature, URL_SAFE_NO_PAD), + }) + .collect(); + Ok(Custom( + Status::Ok, + Json(DelegationStatusResponse::from_status( + request.cid, + status, + effective_expiry, + history, + )), + )) + } None => Ok(Custom( Status::NotFound, Json(DelegationStatusResponse::not_found(request.cid)), @@ -1212,6 +1272,47 @@ fn kv_sync_target( } } +/// Returns the original CID for the exact one-capability renewal control +/// invocation, or rejects malformed renewal capability sets. +fn renewal_target( + capabilities: &[Capability], +) -> Result, (Status, String)> { + let Some(capability) = capabilities + .iter() + .find(|capability| capability.ability.as_ref().as_ref() == "tinycloud.delegation/renew") + else { + return Ok(None); + }; + if capabilities.len() != 1 { + return Err(( + Status::Forbidden, + "Renewal invocation must contain exactly one capability".to_string(), + )); + } + let Resource::Other(resource) = &capability.resource else { + return Err(( + Status::Forbidden, + "Renewal invocation must target an original CID".to_string(), + )); + }; + let Some(cid_text) = resource.as_str().strip_prefix("urn:cid:") else { + return Err(( + Status::Forbidden, + "Renewal invocation must target an original CID".to_string(), + )); + }; + let cid: tinycloud_auth::authorization::Cid = cid_text + .parse() + .map_err(|_| (Status::BadRequest, "Invalid renewal CID".to_string()))?; + if cid.to_string() != cid_text { + return Err(( + Status::BadRequest, + "Renewal CID must be canonical".to_string(), + )); + } + Ok(Some(cid)) +} + /// Decode a `kv/sync` request's headers into its options. The ordinary KV /// precondition and list headers do not apply to the feed. fn kv_sync_invoke_options( @@ -1889,6 +1990,14 @@ async fn invoke_impl( .into()); } + if renewal_target(&admitted.invocation().0.capabilities)?.is_some() { + return Err(( + Status::Forbidden, + "Renewal is managed by the node scheduler".to_owned(), + ) + .into()); + } + // TC-732: `kv/sync` must be the invocation's only capability and // target a KV resource. The KV root is the whole-space feed. Check // shape before any specialized dispatch so mixed invocations fail @@ -5028,6 +5137,51 @@ mod tests { } } + fn renewal_capability(resource: &str) -> Capability { + Capability { + resource: Resource::Other(resource.parse().unwrap()), + ability: Ability::try_from("tinycloud.delegation/renew".to_string()).unwrap(), + caveats: Default::default(), + } + } + + #[test] + fn renewal_target_requires_one_canonical_cid_control_capability() { + let canonical_cid = "bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4"; + let canonical = renewal_capability(&format!("urn:cid:{canonical_cid}")); + assert_eq!( + renewal_target(&[canonical]).unwrap().unwrap().to_string(), + canonical_cid + ); + let malformed = renewal_capability("urn:cid:not-a-cid"); + assert_eq!( + renewal_target(std::slice::from_ref(&malformed)) + .unwrap_err() + .0, + Status::BadRequest + ); + assert_eq!( + renewal_target(&[malformed.clone(), malformed]) + .unwrap_err() + .0, + Status::Forbidden + ); + assert_eq!( + renewal_target(&[renewal_capability("https://example.test/delegation")]) + .unwrap_err() + .0, + Status::Forbidden + ); + assert!(renewal_target(&[]).unwrap().is_none()); + } + + #[test] + fn non_renewal_invocations_keep_generic_dispatch() { + let mut capability = renewal_capability("urn:cid:not-a-cid"); + capability.ability = Ability::try_from("tinycloud.delegation/status".to_string()).unwrap(); + assert!(renewal_target(&[capability]).unwrap().is_none()); + } + /// TC-732 addendum: a list cursor is bound to one listed prefix, so a /// request carrying one alongside two `kv/list` capabilities is refused /// instead of applying the cursor to both lists. @@ -7771,6 +7925,11 @@ mod tests { .dispatch() .await; assert_eq!(response.status(), Status::Forbidden); + assert!(!response + .into_string() + .await + .unwrap_or_default() + .contains("renewal_history")); let response = request( &child_cid, @@ -7782,6 +7941,7 @@ mod tests { let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; assert_eq!(body["status"], "active"); assert_eq!(body["active"], true); + assert_eq!(body["renewal_history"], serde_json::json!([])); let wallet_child_cid = wallet_child_id.to_cid(0x55).to_string(); let response = request( @@ -7800,6 +7960,23 @@ mod tests { assert_eq!(response.status(), Status::Ok); let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; assert_eq!(body["status"], "active"); + let parsed_wallet_cid: AuthCid = wallet_child_cid.parse()?; + let alternate_wallet_cid = + AuthCid::new_v1(0x70, parsed_wallet_cid.hash().to_owned()).to_string(); + let response = request( + &alternate_wallet_cid, + status_header_with_proofs( + &holder_jwk, + &holder_vm, + &holder_did, + &alternate_wallet_cid, + "wallet-session-status-alias", + vec![wallet_session_id.to_cid(0x55)], + )?, + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); let missing_session_id = tinycloud_core::hash::hash(b"missing-session-proof"); for (label, proofs) in [ @@ -7976,7 +8153,30 @@ mod tests { let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; assert_eq!(body["status"], "expired"); assert_eq!(body["expired"], true); + assert_eq!(body["effective_expiry"], 1); + let noncanonical_expired_cid = format!( + "z{}", + bs58::encode(expired_id.to_cid(0x55).to_bytes()).into_string() + ); + assert_ne!(noncanonical_expired_cid, expired_cid); + let response = request( + &noncanonical_expired_cid, + status_header( + &holder_jwk, + &holder_vm, + &holder_did, + &noncanonical_expired_cid, + "expired-base58", + )?, + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let body: serde_json::Value = serde_json::from_str(&response.into_string().await.unwrap())?; + assert_eq!(body["status"], "expired"); + assert_eq!(body["effective_expiry"], 1); + assert_eq!(body["renewal_history"], serde_json::json!([])); let direct_cid = direct_id.to_cid(0x55).to_string(); let response = request( &direct_cid, @@ -10887,6 +11087,49 @@ mod tests { .await?) } + #[tokio::test] + async fn external_invoke_cannot_trigger_a_renewal_stamp() -> Result<()> { + use rocket::http::{ContentType, Header, Status}; + use rocket::local::asynchronous::Client; + use tinycloud_auth::authorization::{make_invocation_from_uris, InvocationOptions}; + + let (_tempdirs, rocket, replay_conn) = tc341_invoke_rocket().await?; + let client = Client::tracked(rocket).await?; + let signer = JWK::generate_ed25519()?; + let verification_method = tc341_verification_method(&signer)?; + let original = tinycloud_core::hash::hash(b"external-renewal-original"); + let original_cid = original.to_cid(0x55).to_string(); + let lease = tinycloud_core::hash::hash(b"external-renewal-lease"); + let lease_cid = lease.to_cid(0x55); + let invocation = make_invocation_from_uris( + [( + format!("urn:cid:{original_cid}").parse()?, + ["tinycloud.delegation/renew".parse::()?], + )], + &lease_cid, + &signer, + &verification_method, + (time::OffsetDateTime::now_utc().unix_timestamp() + 300) as f64, + InvocationOptions { + nonce: Some("urn:uuid:external-renewal-must-not-run".to_string()), + proof: Some(vec![lease_cid]), + ..Default::default() + }, + )?; + + let response = client + .post("/invoke") + .header(Header::new("Authorization", invocation.encode()?)) + .header(ContentType::JSON) + .body("{}") + .dispatch() + .await; + + assert_eq!(response.status(), Status::Forbidden); + assert_eq!(tc341_replay_row_count(&replay_conn).await?, 0); + Ok(()) + } + #[tokio::test] async fn tc341_unverifiable_invocation_rejected_before_replay_insert() -> Result<()> { use rocket::http::{ContentType, Header, Status}; diff --git a/tinycloud-node-server/src/routes/node_keys.rs b/tinycloud-node-server/src/routes/node_keys.rs index 493efce9..f7738f8a 100644 --- a/tinycloud-node-server/src/routes/node_keys.rs +++ b/tinycloud-node-server/src/routes/node_keys.rs @@ -22,29 +22,34 @@ use rocket::{serde::json::Json, State}; use serde::{Deserialize, Serialize}; use tinycloud_core::keys::StaticSecret; -/// The node's published public key material. Public halves only — nothing in -/// this struct can be used to sign. +/// Public key material safe for unauthenticated discovery. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct NodePublicKeys { - /// The node's stable identity DID (`did:key`), also used as the share-v2 - /// enforcer DID and the encryption-module audience. pub node_did: String, - /// Unpadded base64url of the 32-byte ed25519 public half of the Share - /// invitation signing key. This is exactly the value that - /// `shareEmail.invitationPublicKey` (and the trust bundle's - /// `nodeInvitationPublicKey`) must carry for this node. pub share_invitation_public_key: String, + pub renewer_did: String, + pub renewer_public_key: String, + pub renewer_epoch: u64, } impl NodePublicKeys { pub fn derive(secret: &StaticSecret) -> Self { + Self::derive_at_epoch(secret, 1) + } + + pub fn derive_at_epoch(secret: &StaticSecret, epoch: u64) -> Self { + let (renewer_did, _, renewer_public_key) = + tinycloud_core::renewal::renewal_identity_at_epoch(secret, epoch); Self { node_did: secret.node_did(), share_invitation_public_key: encode_config( secret.share_invitation_public_key(), URL_SAFE_NO_PAD, ), + renewer_did, + renewer_public_key: encode_config(renewer_public_key, URL_SAFE_NO_PAD), + renewer_epoch: epoch, } } } @@ -92,15 +97,34 @@ mod tests { } #[test] - fn the_published_record_carries_no_secret_material() { + fn discovery_publishes_the_dedicated_renewer_identity_and_verification_key() { let secret = secret(); - let rendered = serde_json::to_value(NodePublicKeys::derive(&secret)).expect("serialize"); + let published = serde_json::to_value(NodePublicKeys::derive(&secret)).expect("serialize"); + assert_eq!(published.as_object().expect("JSON object").len(), 5); + assert_eq!(published["nodeDid"], secret.node_did()); + assert_eq!(published["renewerDid"], secret.renewal_did()); assert_eq!( - rendered.as_object().expect("object").len(), - 2, - "only nodeDid and shareInvitationPublicKey may be published" + published["renewerPublicKey"], + encode_config( + secret + .renewal_keypair() + .public() + .try_into_ed25519() + .unwrap() + .to_bytes(), + URL_SAFE_NO_PAD + ) ); + assert_ne!(published["renewerDid"], published["nodeDid"]); + assert!(published["renewerEpoch"].is_u64()); + } + + #[test] + fn the_published_record_carries_no_secret_material() { + let secret = secret(); + let rendered = serde_json::to_value(NodePublicKeys::derive(&secret)).expect("serialize"); + let body = rendered.to_string(); assert!(!body.contains(&encode_config(secret.as_bytes(), URL_SAFE_NO_PAD))); assert!(!body.contains(&hex::encode(secret.as_bytes()))); @@ -110,6 +134,10 @@ mod tests { secret.derive_key(b"tinycloud/share-email/invitation-signing"), URL_SAFE_NO_PAD ))); + assert!(!body.contains(&encode_config( + secret.derive_key(tinycloud_core::renewal::RENEWER_KEY_CONTEXT), + URL_SAFE_NO_PAD + ))); } #[rocket::async_test] diff --git a/tinycloud-node-server/tests/fixtures/renewal-state-v1.json b/tinycloud-node-server/tests/fixtures/renewal-state-v1.json new file mode 100644 index 00000000..68f82122 --- /dev/null +++ b/tinycloud-node-server/tests/fixtures/renewal-state-v1.json @@ -0,0 +1,95 @@ +[ + { + "state": "stopped", + "action": "tinycloud.delegation/stop-renewal", + "body": { + "version": 1, + "leaseCid": "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq", + "expectedGeneration": 1 + }, + "claims": { + "att": { + "urn:cid:bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq": { + "tinycloud.delegation/stop-renewal": [ + { + "expectedGeneration": 1 + } + ] + } + }, + "aud": "did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7", + "exp": 1701000060, + "fct": [ + {} + ], + "iss": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S#z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S", + "nbf": 1701000000, + "nnc": "urn:uuid:00000000-0000-4000-8000-000000000001", + "prf": [] + }, + "authorization": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9zdG9wLXJlbmV3YWwiOlt7ImV4cGVjdGVkR2VuZXJhdGlvbiI6MX1dfX0sImF1ZCI6ImRpZDprZXk6ejZNa3BYdmVVeWpwQ0RlNlA4M3JOWXFBWm1vVGM5cW9wSjZHQnVSUVJvdHozekY3IiwiZXhwIjoxNzAxMDAwMDYwLCJmY3QiOlt7fV0sImlzcyI6ImRpZDprZXk6ejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTI3o2TWt0VUx1ZFR0QXNBaFJlZ1lQaVo2NjMxUlYzdml2MTJxZDRHUUY4ejF4QjIyUyIsIm5iZiI6MTcwMTAwMDAwMCwibm5jIjoidXJuOnV1aWQ6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwicHJmIjpbXX0.a7ep_-r1c0emPJJO0p1zQBu9ZGDAbTL1VU_P3GVINGQlwXwIQDkcFm1MWYHGUKGk6qPwGxVEu75FQlGdF5VYDQ", + "signedAscii": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9zdG9wLXJlbmV3YWwiOlt7ImV4cGVjdGVkR2VuZXJhdGlvbiI6MX1dfX0sImF1ZCI6ImRpZDprZXk6ejZNa3BYdmVVeWpwQ0RlNlA4M3JOWXFBWm1vVGM5cW9wSjZHQnVSUVJvdHozekY3IiwiZXhwIjoxNzAxMDAwMDYwLCJmY3QiOlt7fV0sImlzcyI6ImRpZDprZXk6ejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTI3o2TWt0VUx1ZFR0QXNBaFJlZ1lQaVo2NjMxUlYzdml2MTJxZDRHUUY4ejF4QjIyUyIsIm5iZiI6MTcwMTAwMDAwMCwibm5jIjoidXJuOnV1aWQ6MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwicHJmIjpbXX0", + "signatureHex": "6bb7a9ffeaf57347a63c924ed29d73401bbd6460c06d32f5554fcfdc6548346425c17c0840391c166d4c5981c650a1a4eaa3f01b1544bbbe4542519d1795580d" + }, + { + "state": "active", + "action": "tinycloud.delegation/enable-renewal", + "body": { + "version": 1, + "leaseCid": "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq", + "expectedGeneration": 2 + }, + "claims": { + "att": { + "urn:cid:bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq": { + "tinycloud.delegation/enable-renewal": [ + { + "expectedGeneration": 2 + } + ] + } + }, + "aud": "did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7", + "exp": 1701000060, + "fct": [ + {} + ], + "iss": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S#z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S", + "nbf": 1701000000, + "nnc": "urn:uuid:00000000-0000-4000-8000-000000000002", + "prf": [] + }, + "authorization": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9lbmFibGUtcmVuZXdhbCI6W3siZXhwZWN0ZWRHZW5lcmF0aW9uIjoyfV19fSwiYXVkIjoiZGlkOmtleTp6Nk1rcFh2ZVV5anBDRGU2UDgzck5ZcUFabW9UYzlxb3BKNkdCdVJRUm90ejN6RjciLCJleHAiOjE3MDEwMDAwNjAsImZjdCI6W3t9XSwiaXNzIjoiZGlkOmtleTp6Nk1rdFVMdWRUdEFzQWhSZWdZUGlaNjYzMVJWM3ZpdjEycWQ0R1FGOHoxeEIyMlMjejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTIiwibmJmIjoxNzAxMDAwMDAwLCJubmMiOiJ1cm46dXVpZDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIiLCJwcmYiOltdfQ.RJwsIMaEuh9yMRyKvMhHj06BFANolxxan0a1qLEHAcLEXXTnxtXicyEiNgTp-AAS6m8Q4sX_kTN4rEDl2k1-Dg", + "signedAscii": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9lbmFibGUtcmVuZXdhbCI6W3siZXhwZWN0ZWRHZW5lcmF0aW9uIjoyfV19fSwiYXVkIjoiZGlkOmtleTp6Nk1rcFh2ZVV5anBDRGU2UDgzck5ZcUFabW9UYzlxb3BKNkdCdVJRUm90ejN6RjciLCJleHAiOjE3MDEwMDAwNjAsImZjdCI6W3t9XSwiaXNzIjoiZGlkOmtleTp6Nk1rdFVMdWRUdEFzQWhSZWdZUGlaNjYzMVJWM3ZpdjEycWQ0R1FGOHoxeEIyMlMjejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTIiwibmJmIjoxNzAxMDAwMDAwLCJubmMiOiJ1cm46dXVpZDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIiLCJwcmYiOltdfQ", + "signatureHex": "449c2c20c684ba1f72311c8abcc8478f4e81140368971c5a9f46b5a8b10701c2c45d74e7c6d5e27321223604e9f80012ea6f10e2c5ff913378ac40e5da4d7e0e" + }, + { + "state": "read", + "action": "tinycloud.delegation/status", + "body": { + "version": 1, + "leaseCid": "bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq" + }, + "claims": { + "att": { + "urn:cid:bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq": { + "tinycloud.delegation/status": [ + {} + ] + } + }, + "aud": "did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7", + "exp": 1701000060, + "fct": [ + {} + ], + "iss": "did:key:z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S#z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S", + "nbf": 1701000000, + "nnc": "urn:uuid:00000000-0000-4000-8000-000000000003", + "prf": [] + }, + "authorization": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9zdGF0dXMiOlt7fV19fSwiYXVkIjoiZGlkOmtleTp6Nk1rcFh2ZVV5anBDRGU2UDgzck5ZcUFabW9UYzlxb3BKNkdCdVJRUm90ejN6RjciLCJleHAiOjE3MDEwMDAwNjAsImZjdCI6W3t9XSwiaXNzIjoiZGlkOmtleTp6Nk1rdFVMdWRUdEFzQWhSZWdZUGlaNjYzMVJWM3ZpdjEycWQ0R1FGOHoxeEIyMlMjejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTIiwibmJmIjoxNzAxMDAwMDAwLCJubmMiOiJ1cm46dXVpZDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDMiLCJwcmYiOltdfQ.hzLmQd4IObMUOwNXAfKTWnxhfeZ1KM2-ekzW7tyU6ruoPdyftqhjjz3QDblzgjzv8-LD-OkXJmXJI0ceH-b8AQ", + "signedAscii": "eyJhbGciOiJFZERTQSIsImp3ayI6eyJhbGciOiJFZERTQSIsImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMEVxeU1uUXJ0S3M2RTJpOVJoWGs1dEFpU3JjYUFXdXZoU0NqTXNsM2h6YyJ9LCJ0eXAiOiJKV1QiLCJ1Y3YiOiIwLjEwLjAifQ.eyJhdHQiOnsidXJuOmNpZDpiYWZrcjRpZ25oM2lvd2E1Z3lzZ2VndHVvc2E2MnBka3c2bXB2cmhzcjN1bWpleTV4MnpvNGZ2amdxcSI6eyJ0aW55Y2xvdWQuZGVsZWdhdGlvbi9zdGF0dXMiOlt7fV19fSwiYXVkIjoiZGlkOmtleTp6Nk1rcFh2ZVV5anBDRGU2UDgzck5ZcUFabW9UYzlxb3BKNkdCdVJRUm90ejN6RjciLCJleHAiOjE3MDEwMDAwNjAsImZjdCI6W3t9XSwiaXNzIjoiZGlkOmtleTp6Nk1rdFVMdWRUdEFzQWhSZWdZUGlaNjYzMVJWM3ZpdjEycWQ0R1FGOHoxeEIyMlMjejZNa3RVTHVkVHRBc0FoUmVnWVBpWjY2MzFSVjN2aXYxMnFkNEdRRjh6MXhCMjJTIiwibmJmIjoxNzAxMDAwMDAwLCJubmMiOiJ1cm46dXVpZDowMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDMiLCJwcmYiOltdfQ", + "signatureHex": "8732e641de0839b3143b035701f2935a7c617de67528cdbe7a4cd6eedc94eabba83ddc9fb6a8638f3dd00db973823ceff3e2c3f8e9172665c923471e1fe6fc01" + } +] \ No newline at end of file diff --git a/tinycloud-node-server/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index 3cbdb5d8..a8572f3f 100644 --- a/tinycloud-node-server/tests/postgres_round_trip_budget.rs +++ b/tinycloud-node-server/tests/postgres_round_trip_budget.rs @@ -168,7 +168,9 @@ use tokio::{ /// Full-flow ceilings, including asynchronous sign-in work. Details: TC-2214. /// Quota-aware KV puts include the TC-2389 `/invoke` `authorize_admitted` /// check before quota/hosting lookup (`WITH RECURSIVE "proof_roots"`); its -/// net round trip is included in the put ceilings. Fusion follow-up: TC-2426. +/// net round trip is included in the put ceilings. Renewal identity is loaded +/// once at node startup and reused on requests, so it adds no per-request +/// database round trips. Fusion follow-up: TC-2426. const BUDGETS: [(&str, u64); 7] = [ ("sign-in", 71), ("kv put", 43), diff --git a/tinycloud-node-server/tests/renewal_lease_v1_vectors.rs b/tinycloud-node-server/tests/renewal_lease_v1_vectors.rs index 1f9757cf..07336d2f 100644 --- a/tinycloud-node-server/tests/renewal_lease_v1_vectors.rs +++ b/tinycloud-node-server/tests/renewal_lease_v1_vectors.rs @@ -21,6 +21,7 @@ use tinycloud_auth::{ use tinycloud_core::{ hash::hash, keys::{public_key_to_did_key, Keypair, StaticSecret}, + renewal::{period_seconds as runtime_period_seconds, OriginalFormat}, }; const OWNER_SEED: [u8; 32] = [0x11; 32]; @@ -149,6 +150,17 @@ fn renewal_lease_v1_pins_real_owner_signed_ucan_and_exact_original_cid() { .as_seconds(); let original_expiration = original_payload.expiration.as_seconds(); let period_seconds = original_expiration - original_not_before; + assert_eq!( + runtime_period_seconds( + original_expiration as i64, + Some(original_not_before as i64), + None, + OriginalFormat::Ucan, + ) + .unwrap(), + period_seconds as i64, + "the runtime period is derived from the owner's signed original claims", + ); assert_eq!(period_seconds, 2_592_000.0); let TinyCloudDelegation::Ucan(lease_ucan) = @@ -268,3 +280,30 @@ fn renewal_lease_v1_pins_real_owner_signed_ucan_and_exact_original_cid() { "pin the complete signed UCAN and stamp vectors" ); } +#[test] +fn runtime_signer_reproduces_the_frozen_linked_first_stamp() { + assert_eq!(RENEWER_CONTEXT, b"tinycloud/node/renewer/v1"); + let fixture: Value = serde_json::from_str(include_str!("fixtures/renewal-lease-v1.json")) + .expect("renewal lease fixture parses"); + let secret = StaticSecret::new(NODE_STATIC_SECRET.to_vec()).expect("fixed test secret"); + let signer = tinycloud_core::renewal::RenewalSigner::from_seed( + secret.derive_key(RENEWER_CONTEXT), + secret.node_did(), + 1, + ); + let stamp = &fixture["firstStamp"]; + let unsigned = &stamp["stamp"]["unsigned"]; + let (bytes, signature, cid) = signer + .sign_stamp( + fixture["originalCid"].as_str().unwrap(), + fixture["leaseCid"].as_str().unwrap(), + unsigned["leaseGeneration"].as_u64().unwrap(), + unsigned["previousStampCid"].as_str().unwrap(), + unsigned["newEnd"].as_str().unwrap(), + ) + .expect("runtime stamp encoding succeeds"); + + assert_eq!(hex::encode(&bytes), stamp["canonicalDagCborHex"]); + assert_eq!(cid, stamp["stampCid"]); + assert_eq!(hex::encode(&signature), stamp["signatureHex"]); +} diff --git a/tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs b/tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs index b24d03a4..96b6b4c0 100644 --- a/tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs +++ b/tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs @@ -5,7 +5,7 @@ //! seed is synthetic and public; it is not a production key. use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; -use serde_json::{json, Value}; +use serde_json::Value; use tinycloud_core::{hash::hash, keys::StaticSecret}; fn raw_blake3_cid(bytes: &[u8]) -> String { @@ -28,10 +28,7 @@ fn renewal_stamp_v1_matches_pinned_bytes_cid_and_signature() { let domain = fixture["stampDomain"].as_str().unwrap(); let master_secret = decode_hex(&fixture["testOnlyMasterSecretHex"]); let node_secret = StaticSecret::new(master_secret).expect("32-byte test seed"); - let signing_seed: [u8; 32] = node_secret - .derive_key(key_context) - .try_into() - .expect("fixed-length derivation"); + let signing_seed: [u8; 32] = node_secret.derive_key(key_context); let signer = SigningKey::from_bytes(&signing_seed); let original_cid = raw_blake3_cid(&dag_cbor(&fixture["originalArtifact"])); @@ -87,3 +84,45 @@ fn renewal_stamp_v1_matches_pinned_bytes_cid_and_signature() { "DAG-CBOR re-encoding is byte-identical" ); } +#[test] +fn runtime_signer_reproduces_the_frozen_stamp_vector() { + let fixture: Value = serde_json::from_str(include_str!("fixtures/renewal-stamp-v1.json")) + .expect("renewal vector JSON parses"); + assert_eq!( + tinycloud_core::renewal::STAMP_DOMAIN, + "tinycloud/node/renewal-stamp/v1" + ); + assert_eq!( + tinycloud_core::renewal::RENEWER_KEY_CONTEXT, + b"tinycloud/node/renewer/v1" + ); + + let master_secret = decode_hex(&fixture["testOnlyMasterSecretHex"]); + let node_secret = StaticSecret::new(master_secret).expect("32-byte test seed"); + let signer = tinycloud_core::renewal::RenewalSigner::from_seed( + node_secret.derive_key(tinycloud_core::renewal::RENEWER_KEY_CONTEXT), + node_secret.node_did(), + fixture["stamp"]["unsigned"]["renewerEpoch"] + .as_u64() + .unwrap(), + ); + let original_cid = raw_blake3_cid(&dag_cbor(&fixture["originalArtifact"])); + let lease_cid = raw_blake3_cid(&dag_cbor(&fixture["leaseArtifact"])); + let (bytes, signature, cid) = signer + .sign_stamp( + &original_cid, + &lease_cid, + fixture["stamp"]["unsigned"]["leaseGeneration"] + .as_u64() + .unwrap(), + fixture["stamp"]["unsigned"]["previousStampCid"] + .as_str() + .unwrap(), + fixture["stamp"]["unsigned"]["newEnd"].as_str().unwrap(), + ) + .expect("runtime stamp encoding succeeds"); + + assert_eq!(hex::encode(bytes), fixture["canonicalDagCborHex"]); + assert_eq!(cid, fixture["stampCid"]); + assert_eq!(hex::encode(signature), fixture["signatureHex"]); +}