From 068c3ce0a30399f0b018d32b86bad057f50101d9 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:39:45 +0000 Subject: [PATCH 01/12] feat: implement bounded node delegation renewal Details: TC-2106 --- CHANGELOG.md | 1 + Cargo.lock | 2 + docs/specs/renewal-lease-v1.md | 2 +- docs/specs/renewal-stamp-v1.md | 30 +- tinycloud-core/Cargo.toml | 2 + tinycloud-core/src/auth_graph.rs | 682 ++++++- tinycloud-core/src/db.rs | 219 ++- tinycloud-core/src/keys.rs | 41 + tinycloud-core/src/lib.rs | 1 + .../m20261111_000000_delegation_renewals.rs | 180 ++ tinycloud-core/src/migrations/mod.rs | 2 + tinycloud-core/src/models/delegation.rs | 281 ++- tinycloud-core/src/models/invocation.rs | 13 +- tinycloud-core/src/models/revocation.rs | 83 +- tinycloud-core/src/renewal.rs | 1614 +++++++++++++++++ tinycloud-node-server/src/auth_guards.rs | 24 + tinycloud-node-server/src/lib.rs | 1 + tinycloud-node-server/src/renewal.rs | 133 ++ tinycloud-node-server/src/routes/mod.rs | 280 ++- tinycloud-node-server/src/routes/node_keys.rs | 50 +- 20 files changed, 3550 insertions(+), 91 deletions(-) create mode 100644 tinycloud-core/src/migrations/m20261111_000000_delegation_renewals.rs create mode 100644 tinycloud-core/src/renewal.rs create mode 100644 tinycloud-node-server/src/renewal.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 21783e7a0..973bcad9e 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; stop/re-enable controls are not included, so revocation is the only way to turn renewal off and cannot be undone. - 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 1d36fea5f..820b1fe39 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/specs/renewal-lease-v1.md b/docs/specs/renewal-lease-v1.md index 96f709ae8..33e21ce34 100644 --- a/docs/specs/renewal-lease-v1.md +++ b/docs/specs/renewal-lease-v1.md @@ -17,7 +17,7 @@ The original token is signed by the test owner and grants `tinycloud.kv/get` to The original and lease CIDs are CIDv1 raw (`0x55`) with Blake3-256 over the exact token bytes returned by `TinyCloudDelegation::decode`. The lease's resource is derived from the canonical CID of the signed original UCAN, not from a client-provided digest. The full lease bytes are the exact `leaseToken` string in the fixture encoded as UTF-8; the original's exact bytes are its `originalToken` string encoded as UTF-8. -The period in v1 is the owner's signed original UCAN `exp - nbf`; if `nbf` is absent, the signed issued-at value is the anchor. This vector's original period is 30 days (`2,592,000` seconds). V1 has no lease period override. The owner-signed lease expiry is a separate upper bound: this vector's lease lifetime is 365 days (`31,536,000` seconds). The test derives both values from the signed UCAN claims and checks that `newEnd = min(now + period, lease expiry)` respects the lease cap. +The period in v1 is the owner's signed original grant `exp - nbf`. Only for a CACAO original, if `nbf` is absent, the signed issued-at value is the anchor; a UCAN without `nbf` is rejected. Missing expiry, missing CACAO anchors, malformed dates, or a non-positive period are rejected. This vector's original period is 30 days (`2,592,000` seconds). V1 has no lease period override. The owner-signed lease expiry is a separate upper bound: this vector's lease lifetime is 365 days (`31,536,000` seconds). Renewal issuance computes `newEnd = min(now + period, lease expiry, each ancestor's effective end)`. At issuance and stamp verification, `newEnd` must not exceed the lease expiry or any ancestor's effective end; at acceptance it must also satisfy `newEnd <= verifier_now + period`. The period is derived from signed claims, not a caller value. The `firstStamp` entry is signed by the derived renewer key and pins canonical DAG-CBOR bytes, detached signature, and CID. Its `leaseCid` and non-null `previousStampCid` both equal the actual signed lease CID `bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq`; the test asserts both links. diff --git a/docs/specs/renewal-stamp-v1.md b/docs/specs/renewal-stamp-v1.md index 9681ad437..2fa67b39b 100644 --- a/docs/specs/renewal-stamp-v1.md +++ b/docs/specs/renewal-stamp-v1.md @@ -1,15 +1,15 @@ # Renewal stamp v1 wire contract -This file records the exact v1 signed-stamp representation pinned by fixture commit `377c0cd9269c982bb2e71b60a51bc77367718031`, based on main `d63d4c44ef274d24f67a7f889379226e4769e261`. The selected architecture is the Bobbie-reviewed design `1635acfc1c4d9f965a58101eb5f8a0dcb45f39ef0996225021597157ac757a53`. This specification freezes the wire contract only; it does not implement runtime renewal behavior. +This file records the exact v1 signed-stamp representation pinned by fixture commit `377c0cd9269c982bb2e71b60a51bc77367718031`, the owner-signed lease and linked first stamp pinned by #546 commit `bd2f95ca02ff25f4c67890ad0770ff0451c25295`, and the Bobbie-reviewed design `1635acfc1c4d9f965a58101eb5f8a0dcb45f39ef0996225021597157ac757a53`. Runtime code must enforce the validity bounds below. ## Renewal lease authority -A renewal lease is an existing owner-authorized UCAN/CACAO delegation. This specification does **not** define a new lease serialization, lease signature format, or a real signed lease vector. Its exact control capability is: +A renewal lease is a real owner-signed UCAN/CACAO DAG delegation addressed to the node renewer DID. Its exact control capability is: - Ability: `tinycloud.delegation/renew` - Resource: `urn:cid:` -The CIDs named `originalCid` and `leaseCid` in the known-answer fixture are generated from synthetic DAG-CBOR placeholder artifacts. They test stamp encoding and the non-null root link only; they are not signed UCAN/CACAO delegation records and do not freeze the lease's actual bytes. +The #546 fixture pins the signed lease token and CID. Its linked first stamp sets both `leaseCid` and `previousStampCid` to that real lease CID. The signed original grant supplies the renewal period; the owner-signed lease expiry is a separate upper bound. For UCAN, `nbf` is required. Only CACAO may use its signed issued-at claim when `nbf` is absent. ## Signed stamp bytes @@ -38,18 +38,20 @@ Encode with the repository's deterministic `serde_ipld_dagcbor` representation. Derive the renewer signing seed from the node's `StaticSecret` with the exact context bytes `tinycloud/node/renewer/v1`, using `StaticSecret::derive_key` (Blake3-256 of the static secret followed by the context). This is distinct from the node's identity-key context and storage-key generation. -## Pinned vector +Verification uses the public key for the renewer DID in the exact owner-signed lease audience; the stable `nodeDid` is not a signing key. A stamp is valid only when its signature verifies under that audience key and its original CID, lease CID, epoch, and previous-stamp link match the registered chain. At creation, `newEnd` is `min(now + signed original period, lease expiry, every ancestor's effective end)`. At creation and verification, `newEnd` MUST be no later than the lease expiry or any ancestor's effective end; at acceptance it MUST also be no later than verifier time plus the signed original period. -The synthetic fixture is `tinycloud-node-server/tests/fixtures/renewal-stamp-v1.json`; its assertions are in `tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs`. The fixture's fixed master-secret bytes (`42` repeated 32 times) are public test data only. +## Pinned linked lease and stamp vector -- Original placeholder CID: `bafkr4ifaiwrq52xkndrm7jbwqvywq3tkilydyxrj24oofckit6ink7f6hy` -- Lease placeholder CID and first `previousStampCid`: `bafkr4iaiwsgu3x5bvuigtms3hrlhrwpshbv5lxar5oj7uhh5dfg2sbcl4q` -- Stamp CID: `bafkr4ibtywvyzwld56wljgytygpis2dddualt6h6b343e52rzmsegyw5nq` -- Signer public key (hex): `49fd44f3fe8930b0e89f61c51333195578deacaf22105a78f647d8c05afc7c5d` -- Detached signature (hex): `34caf6cc60615a4c0d4f4e934c0263dde1c17fdd5becfa0f693337987b0250f0b430d7fe4518db41a882cc26ee979603736b883798fb83bbc95fb61af077020d` +The signed lease, original grant, linked first stamp, and assertions are in `tinycloud-node-server/tests/fixtures/renewal-lease-v1.json` and `tinycloud-node-server/tests/renewal_lease_v1_vectors.rs`. The exact lease and first-stamp vectors were independently verified for #546 at `bd2f95ca02ff25f4c67890ad0770ff0451c25295`. -Exact canonical DAG-CBOR bytes (hex): +- Original CID: `bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4` +- Lease CID and first `previousStampCid`: `bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq` +- First stamp CID: `bafkr4iewc5ewhaccw5xh3cvxokvkehjvf76atyhvftq4i4eq7ayneimt3e` +- Signer DID: the dedicated renewer DID in the lease audience, distinct from the stable node identity DID. -```text -a266646f6d61696e781f74696e79636c6f75642f6e6f64652f72656e6577616c2d7374616d702f763168756e7369676e6564a964747970657554696e79436c6f756452656e6577616c5374616d70666e6577456e6474323033302d30312d30325430333a30343a30355a676e6f646544696478386469643a6b65793a7a364d6b7058766555796a7043446536503833724e5971415a6d6f546339716f704a364742755251526f747a337a46376776657273696f6e01686c65617365436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c34716b6f726967696e616c436964783b6261666b7234696661697772713532786b6e64726d376a6277717679777133746b696c79647978726a32346f6f66636b697436696e6b37663668796c72656e6577657245706f6368076f6c6561736547656e65726174696f6e037070726576696f75735374616d70436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c3471 -``` +The separate `renewal-stamp-v1.json` vector remains a stamp-only canonical-byte test. It is not the linked lease vector. +The linked vector's exact DAG-CBOR bytes, detached signature, signer key and recomputed CIDs are pinned in `renewal-lease-v1.json`; the stamp-only fixture is not authoritative for lease linkage. + +## Current node slice boundary + +The current node slice implements renewal issuance/use with existing revoke and status behavior. It does not expose STOP or re-enable operations; those are deferred until their owner-authenticated exact-CID control contract is separately frozen and reviewed. Until then, the owner's only way to turn renewal off is to revoke the lease. Revocation is irreversible and immediately invalidates renewed access; it cannot be re-enabled. This delivery boundary does not change the D13/D18 target behavior. diff --git a/tinycloud-core/Cargo.toml b/tinycloud-core/Cargo.toml index 7ed5c71fe..ed2ad1d64 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 c0b04f784..495ca9ac8 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").into(); + 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").into(), + 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,82 @@ 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})" + )) + .into(); + 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 +681,134 @@ 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( + &candidate.signed_bytes, + &candidate.signature, + &candidate.stamp, + &candidate.original, + &candidate.lease, + candidate.generation, + &candidate.previous, + candidate.end, + &candidate.did, + candidate.epoch, + &candidate.audience, + stamp_verifier_now, + candidate.period, + candidate.lease_expiry, + &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 +817,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 +853,7 @@ impl AuthGraphSnapshot { delegations: HashMap::new(), abilities: HashMap::new(), revoked: HashSet::new(), + renewal_effective_ends: HashMap::new(), }); } @@ -581,6 +864,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 +906,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 !matches!(state.as_str(), "active" | "stopped") + || 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 +985,64 @@ 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(); + let current = if active.len() == 1 { + Some(active[0]) + } else if active.is_empty() { + let stopped: Vec<_> = leases + .values() + .filter(|rows| rows[0].state == "stopped") + .collect(); + if stopped.len() == 1 { + Some(stopped[0]) + } else { + None + } + } else { + None + }; + if let Some(rows) = current { + candidate_map.insert(node, rows.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 +1145,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 +1157,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 +1183,251 @@ 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()) + } + + 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()); + 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,'stopped',0,'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, + 0, + &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,0,$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(); + assert!( + 2_100 < 2_150, + "the linked predecessor is expired at verification time" + ); + 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 (stable_did, renewer_did, epoch) = + insert_snapshot_renewal(&db, original, lease, ancestor, 2_000, 2_250, 1_000, false) + .await; + 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:did:key:other: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 graph = AuthGraphSnapshot::load_with_renewal_identity( + &db, + &[original], + &stable_did, + &renewer_did, + epoch, + 2_150, + ) + .await + .unwrap(); + let capability = crate::util::Capability { + resource: "tinycloud:did:key:other:files/kv/doc".parse().unwrap(), + ability: "tinycloud.kv/get".to_owned().try_into().unwrap(), + caveats: Caveats::default(), + }; + crate::models::invocation::authorize_holder_with_graph( + &db, + "did:key:actor", + &[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 +1440,7 @@ mod tests { delegations: HashMap::new(), abilities: HashMap::new(), revoked: HashSet::new(), + renewal_effective_ends: HashMap::new(), }); } let delegations = delegation::Entity::find() @@ -830,17 +1473,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 +1572,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 +1694,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 +1743,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 +1836,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 032ffd2c2..ecdc5ee36 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -356,6 +356,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)] @@ -705,8 +707,17 @@ 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, + { + crate::renewal::issue_stamp(&self.conn, request, &self.secrets).await + } } - impl SpaceDatabase { /// Exposes the node's capability database for projections that are /// committed alongside ordinary delegation/revocation writes. @@ -878,7 +889,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 +920,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, @@ -2066,6 +2104,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 +2140,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 +2180,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,6 +2210,19 @@ 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, @@ -2366,6 +2438,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 +2844,24 @@ 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.secrets.renewal_node_did(), self.secrets.renewal_seed()) { + (Some(stable_node_did), Some(seed)) => { + let (renewer_did, epoch, _) = + crate::renewal::renewal_identity_from_seed(seed); + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &tx, + &roots, + &stable_node_did, + &renewer_did, + epoch, + now.unix_timestamp(), + ) + .await + } + _ => crate::auth_graph::AuthGraphSnapshot::load(&tx, &roots).await, + }; + let auth_graph = match auth_graph_result { Ok(graph) => graph, Err(error) => { crate::telemetry::observe_stage( @@ -3145,18 +3242,29 @@ where .as_ref() .and_then(|sync| sync.retention_grant), ); - Some( - crate::auth_graph::AuthGraphSnapshot::load(&self.conn, &roots) - .await - .map_err(|error| match error { - revocation::ChainTraversalError::Db(error) => { - TxError::::Db(error) - } - revocation::ChainTraversalError::LimitExceeded => { - TxError::ChainTraversalLimitExceeded - } - })?, - ) + let graph_result = + match (self.secrets.renewal_node_did(), self.secrets.renewal_seed()) { + (Some(stable_node_did), Some(seed)) => { + let (renewer_did, epoch, _) = + crate::renewal::renewal_identity_from_seed(seed); + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &self.conn, + &roots, + &stable_node_did, + &renewer_did, + epoch, + now.unix_timestamp(), + ) + .await + } + _ => 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 +3825,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)] @@ -3913,6 +4023,26 @@ 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 = + secrets + .renewal_node_did() + .zip(renewal_seed) + .map(|(stable_node_did, seed)| { + let (renewer_did, epoch, _) = crate::renewal::renewal_identity_from_seed(seed); + (stable_node_did, renewer_did, 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 +4051,27 @@ 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, _, _))) = (renewal_seed, renewal_identity.as_ref()) { + for lease in renewal_lease_events { + crate::renewal::register_lease(db, lease, seed, 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 +5893,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 cde4a1d3d..492d42157 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 7f006a159..e2becab3e 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 000000000..8e0a2bc34 --- /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/mod.rs b/tinycloud-core/src/migrations/mod.rs index a3c1c49bb..73529f0a7 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -34,6 +34,7 @@ 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 struct Migrator; @@ -76,6 +77,7 @@ 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), ] } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 4a6da7ffb..52b82be45 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) { @@ -1798,7 +1890,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 +1907,174 @@ 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(), + ); + let (signed_bytes, signature, stamp_cid) = signer + .sign_stamp(&original_cid, &lease_cid, 0, &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',0,'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,0,$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 +2561,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 a835a23d2..0d97fcc01 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 814a35867..48a4a70fb 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::{ @@ -375,13 +375,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", } } @@ -538,6 +539,18 @@ pub(crate) async fn control_proof_decision( 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 +1246,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',0,'did:key:owner','did:key:renewer',0,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::Scoped(_))); + } + } + 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 000000000..6adcc150a --- /dev/null +++ b/tinycloud-core/src/renewal.rs @@ -0,0 +1,1614 @@ +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 serde_json::json; +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, +} + +#[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 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) +} +/// Verify the frozen signed stamp representation against its registered chain. +pub(crate) fn verify_snapshot_stamp( + signed_bytes: &[u8], + signature_bytes: &[u8], + expected_stamp_cid: &str, + expected_original_cid: &str, + expected_lease_cid: &str, + generation: u64, + previous_stamp_cid: &str, + new_end: i64, + node_did: &str, + renewer_epoch: u64, + audience_public_key: &[u8; 32], + verifier_now: i64, + period: i64, + lease_expiry: i64, + ancestor_ends: &[i64], +) -> Result<(), RenewalStoreError> { + verify_stamp_signature(signed_bytes, signature_bytes, audience_public_key)?; + let signed: serde_json::Value = 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 + .get("unsigned") + .ok_or(RenewalStoreError::InvalidInput)?; + 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.as_object().is_none_or(|map| map.len() != 2) + || unsigned.as_object().is_none_or(|map| map.len() != 9) + || signed.get("domain").and_then(serde_json::Value::as_str) != Some(STAMP_DOMAIN) + || unsigned.get("type").and_then(serde_json::Value::as_str) != Some("TinyCloudRenewalStamp") + || unsigned.get("version").and_then(serde_json::Value::as_u64) != Some(1) + || unsigned + .get("originalCid") + .and_then(serde_json::Value::as_str) + != Some(&expected_original_cid) + || unsigned.get("leaseCid").and_then(serde_json::Value::as_str) != Some(&expected_lease_cid) + || unsigned + .get("leaseGeneration") + .and_then(serde_json::Value::as_u64) + != Some(generation) + || unsigned + .get("previousStampCid") + .and_then(serde_json::Value::as_str) + != Some(&previous_stamp_cid) + || unsigned.get("nodeDid").and_then(serde_json::Value::as_str) != Some(node_did) + || unsigned + .get("renewerEpoch") + .and_then(serde_json::Value::as_u64) + != Some(renewer_epoch) + || unsigned.get("newEnd").and_then(serde_json::Value::as_str) != Some(&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(()) +} + +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) -> Self { + let key = SigningKey::from_bytes(&seed); + let digest = blake3::hash(&key.verifying_key().to_bytes()); + let renewer_epoch = u64::from_be_bytes(digest.as_bytes()[..8].try_into().unwrap()); + Self { + key, + 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 unsigned = json!({ + "type": "TinyCloudRenewalStamp", + "version": 1, + "originalCid": original_cid, + "leaseCid": lease_cid, + "leaseGeneration": lease_generation, + "previousStampCid": previous_stamp_cid, + "nodeDid": self.node_did, + "renewerEpoch": self.renewer_epoch, + "newEnd": new_end, + }); + let signed = json!({"domain": STAMP_DOMAIN, "unsigned": unsigned}); + 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, its stable key epoch, and raw public key. +pub fn renewal_identity(secret: &StaticSecret) -> (String, u64, [u8; 32]) { + renewal_identity_from_seed(secret.derive_key(RENEWER_KEY_CONTEXT)) +} + +pub fn renewal_identity_from_seed(seed: [u8; 32]) -> (String, u64, [u8; 32]) { + let signer = SigningKey::from_bytes(&seed); + let bytes = signer.verifying_key().to_bytes(); + let digest = blake3::hash(&bytes); + let epoch = u64::from_be_bytes(digest.as_bytes()[..8].try_into().unwrap()); + 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 +} + +/// The control capability is exact: no prefix, alternate service or resource form. +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() +} + +pub async fn lease_for_original( + db: &C, + original_cid: &str, +) -> Result, RenewalStoreError> { + let (original_cid, original) = canonical_cid(original_cid)?; + if is_revoked_chain(db, &original).await? { + return Ok(None); + } + let now = db_datetime(OffsetDateTime::now_utc().unix_timestamp())?; + let rows = db.query_all(Statement::from_sql_and_values( + db.get_database_backend(), + "SELECT lease_cid, grantor, renewer_did, renewer_epoch, lease_expiry, state, generation FROM renewal_lease WHERE original_canonical = $1 AND state = 'active' AND lease_expiry > $2 ORDER BY lease_cid", + [ + Value::Bytes(Some(Box::new(Vec::::from(original)))), + Value::TimeDateTime(Some(Box::new(now))), + ], + )).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(), + state: row.try_get("", "state")?, + generation: row.try_get::("", "generation")? as u64, + }); + } + Ok(selected) +} + +/// Authorize a dedicated `/invoke` control request without the generic graph +/// authorization or the space-owner shortcuts. +pub async fn authorize_renewal_invocation( + db: &C, + invocation: &crate::util::InvocationInfo, + original_cid: &str, +) -> 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(db, original_cid) + .await? + .ok_or(RenewalStoreError::LeaseUnavailable)?; + if invocation.invoker != lease.renewer_did { + 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( + db, + &invocation.invoker, + &invocation.parents, + ControlAction::Renew, + &target, + ) + .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], + stable_node_did: &str, + encryption: Option<&crate::encryption::ColumnEncryption>, +) -> 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, renewer_epoch, audience_public_key) = + renewal_identity_from_seed(renewal_seed); + if lease.delegator != original_info.delegator || lease.delegate != renewer_did { + return Err(RenewalStoreError::Unauthorized); + } + lock_original(tx, &original).await?; + let now = OffsetDateTime::now_utc().unix_timestamp(); + 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", + [Value::Bytes(Some(Box::new(Vec::::from(original))))], + )).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? { + return Err(RenewalStoreError::LeaseUnavailable); + } + 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', 0, $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, +) -> 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?; + authorize_renewal_invocation(&tx, &request.invocation, &request.original_cid).await?; + issue_stamp_in_transaction(&tx, request, secret).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, +) -> Result { + let (original_cid, original) = canonical_cid(&request.original_cid)?; + let (lease_cid, lease) = canonical_cid(&request.lease_cid)?; + let now = OffsetDateTime::now_utc().unix_timestamp(); + if request.replay_expires_at <= now { + return Err(RenewalStoreError::InvalidInput); + } + 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_did, current_epoch, _) = renewal_identity(secret); + if state != "active" || now >= lease_expiry.unix_timestamp() { + return Err(RenewalStoreError::LeaseUnavailable); + } + if renewer_did != current_did || renewer_epoch != current_epoch as i64 { + return Err(RenewalStoreError::IdentityEpoch); + } + let snapshot = load_renewal_snapshot( + tx, + &[original, lease], + &secret.node_did(), + ¤t_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 <= now { + return Err(RenewalStoreError::LeaseUnavailable); + } + let signer = + RenewalSigner::from_seed(secret.derive_key(RENEWER_KEY_CONTEXT), secret.node_did()); + 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, + )?; + if let Err(error) = tx + .execute(Statement::from_sql_and_values( + 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()); + } + 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, + secret: &StaticSecret, +) -> Result, RenewalStoreError> { + let (_, original) = canonical_cid(original_cid)?; + let (_, lease) = canonical_cid(lease_cid)?; + let (renewer_did, renewer_epoch, _) = renewal_identity(secret); + let snapshot = load_renewal_snapshot( + db, + &[original, lease], + &secret.node_did(), + &renewer_did, + renewer_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) = canonical_cid(original_cid)?; + 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 sea_orm::sea_query::Value; + use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseBackend, Statement}; + use sea_orm_migration::MigratorTrait; + use time::OffsetDateTime; + + #[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()); + 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()); + 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()); + let (bytes, signature, stamp) = signer + .sign_stamp(original, lease, 0, lease, "1970-01-01T00:33:20Z") + .unwrap(); + verify_snapshot_stamp( + &bytes, + &signature, + &stamp, + original, + lease, + 0, + lease, + 2000, + &signer.node_did, + signer.renewer_epoch, + &signer.public_key(), + 1000, + 2000, + 4000, + &[3000], + ) + .unwrap(); + assert!(matches!( + verify_snapshot_stamp( + &bytes, + &signature, + &stamp, + original, + lease, + 0, + lease, + 2000, + "did:key:wrong-node", + signer.renewer_epoch, + &signer.public_key(), + 1000, + 2000, + 4000, + &[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()); + 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( + bytes, + signature, + cid, + original, + lease, + 0, + previous, + end, + &signer.node_did, + signer.renewer_epoch, + &signer.public_key(), + 2_000, + 5_000, + 9_000, + &[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(); + assert!(3_000 > 2_000); + } + + #[test] + fn renewer_epoch_is_restart_stable_and_rotates_with_secret() { + 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).1, renewal_identity(&rotated).1); + assert!(is_renewer_did(&first, &renewal_identity(&same).0)); + } + + #[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 secret = StaticSecret::new(vec![4; 32]).unwrap(); + let (renewer_did, renewer_epoch, audience) = renewal_identity(&secret); + 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 now = OffsetDateTime::now_utc().unix_timestamp(); + let end = now + 1000; + let grantor = "did:key:renewal-owner"; + let tx = crate::db::begin_write_transaction(&db, None).await.unwrap(); + for actor in [ + grantor.to_owned(), + renewer_did.clone(), + "did:key:child".to_owned(), + "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(); + } + let insert_delegation = |id: Hash, delegator: String, delegatee: String, expiry: i64| { + 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(vec![1]))), + ], + ) + }; + tx.execute(insert_delegation( + ancestor, + grantor.to_owned(), + "did:key:child".to_owned(), + end, + )) + .await + .unwrap(); + tx.execute(insert_delegation( + original, + grantor.to_owned(), + "did:key:holder".to_owned(), + now + 5000, + )) + .await + .unwrap(); + tx.execute(insert_delegation( + lease, + grantor.to_owned(), + renewer_did.clone(), + now + 6000, + )) + .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',0,$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(now + 6000).unwrap()))), + Value::TimeDateTime(Some(Box::new(db_datetime(now + 5000).unwrap()))), + Value::BigInt(Some(10_000)), + 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()) + .public_key(), + audience + ); + tx.commit().await.unwrap(); + + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../tinycloud-node-server/tests/fixtures/renewal-lease-v1.json" + )) + .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(&db, &wrong_caller, original_cid).await, + Err(RenewalStoreError::Unauthorized) + )); + authorize_renewal_invocation(&db, &request.invocation, original_cid) + .await + .unwrap(); + let issued = issue_stamp(&db, request.clone(), &secret).await.unwrap(); + assert!(issued.new_end <= end); + assert!(matches!( + issue_stamp(&db, request, &secret).await, + Err(RenewalStoreError::Replay) + )); + + assert_eq!( + effective_end(&db, original_cid, lease_cid, now, &secret) + .await + .unwrap(), + Some(issued.new_end) + ); + 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',0,$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()); + let (signed_bytes, signature, _) = signer + .sign_stamp(original_cid, lease_cid, 0, lease_cid, &renewed_end_text) + .unwrap(); + 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,0,$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(signature))), + Value::Bytes(Some(Box::new(hash(&signed_bytes).as_ref().to_vec()))), + ], + )) + .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-node-server/src/auth_guards.rs b/tinycloud-node-server/src/auth_guards.rs index 8c2115250..8011f7c36 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 a2d6110d8..8ba639dfe 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; diff --git a/tinycloud-node-server/src/renewal.rs b/tinycloud-node-server/src/renewal.rs new file mode 100644 index 000000000..33d9714bf --- /dev/null +++ b/tinycloud-node-server/src/renewal.rs @@ -0,0 +1,133 @@ +pub use tinycloud_core::renewal::{ + new_end, period_seconds, validate_stamp_end, verify_stamp_signature, OriginalFormat, + RenewalError, RenewalSigner, RENEWER_KEY_CONTEXT, STAMP_DOMAIN, +}; + +#[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()); + 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 747b3665a..3ab614ad5 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,15 @@ pub struct DelegationStatusResponse { pub active: bool, pub revoked: bool, pub expired: bool, + pub renewal_history: Vec, } impl DelegationStatusResponse { - fn from_status(cid: String, status: DelegationStatus) -> Self { + fn from_status( + cid: String, + status: DelegationStatus, + renewal_history: Vec, + ) -> Self { let status_name = match status { DelegationStatus::Active => "active", DelegationStatus::Revoked => "revoked", @@ -609,6 +625,7 @@ impl DelegationStatusResponse { active: status == DelegationStatus::Active, revoked: status == DelegationStatus::Revoked, expired: status == DelegationStatus::Expired, + renewal_history, } } @@ -620,6 +637,7 @@ impl DelegationStatusResponse { active: false, revoked: false, expired: false, + renewal_history: Vec::new(), } } } @@ -667,10 +685,40 @@ 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 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, + history, + )), + )) + } None => Ok(Custom( Status::NotFound, Json(DelegationStatusResponse::not_found(request.cid)), @@ -1211,6 +1259,112 @@ fn kv_sync_target( )), } } +async fn require_empty_renewal_body(data: DataIn<'_>) -> Result<(), (Status, String)> { + match data { + DataIn::None => Ok(()), + DataIn::One(data) => require_empty_renewal_reader(data.open(1u8.bytes())).await, + DataIn::Many(_) => Err(( + Status::BadRequest, + "Renewal invocation must not include a body".to_string(), + )), + } +} + +async fn require_empty_renewal_reader( + mut reader: R, +) -> Result<(), (Status, String)> { + let mut byte = [0u8; 1]; + let read = reader + .read(&mut byte) + .await + .map_err(|error| (Status::BadRequest, error.to_string()))?; + if read == 0 { + Ok(()) + } else { + Err(( + Status::BadRequest, + "Renewal invocation must not include a body".to_string(), + )) + } +} + +/// 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)) +} + +fn map_renewal_error( + error: tinycloud_core::renewal::RenewalStoreError, + write_may_have_applied: bool, +) -> InvokeError { + let (status, message) = match error { + tinycloud_core::renewal::RenewalStoreError::Unauthorized => ( + Status::Forbidden, + "Renewal invocation is not authorized".to_string(), + ), + tinycloud_core::renewal::RenewalStoreError::Revoked => ( + Status::Forbidden, + "Renewal delegation is revoked".to_string(), + ), + tinycloud_core::renewal::RenewalStoreError::LeaseUnavailable => { + (Status::Conflict, "Renewal lease is unavailable".to_string()) + } + tinycloud_core::renewal::RenewalStoreError::Replay => { + (Status::Conflict, "Renewal invocation replayed".to_string()) + } + tinycloud_core::renewal::RenewalStoreError::IdentityEpoch => ( + Status::Forbidden, + "Renewal identity has changed".to_string(), + ), + tinycloud_core::renewal::RenewalStoreError::InvalidInput + | tinycloud_core::renewal::RenewalStoreError::Invalid(_) => { + (Status::BadRequest, "Invalid renewal request".to_string()) + } + tinycloud_core::renewal::RenewalStoreError::Db(error) => database_failure( + Service::Other, + &error, + write_may_have_applied, + "Renewal unavailable".to_string(), + ), + }; + InvokeError::from((status, message)) +} /// Decode a `kv/sync` request's headers into its options. The ordinary KV /// precondition and list headers do not apply to the feed. @@ -1889,6 +2043,54 @@ async fn invoke_impl( .into()); } + if let Some(original_cid) = renewal_target(&admitted.invocation().0.capabilities)? { + require_empty_renewal_body(data).await?; + let original_cid = original_cid.to_string(); + tinycloud_core::renewal::authorize_renewal_invocation( + tinycloud.connection(), + &admitted.invocation().0, + &original_cid, + ) + .await + .map_err(|error| map_renewal_error(error, false))?; + let lease = tinycloud_core::renewal::lease_for_original( + tinycloud.connection(), + &original_cid, + ) + .await + .map_err(|error| map_renewal_error(error, false))? + .ok_or_else(|| { + InvokeError::from((Status::Forbidden, "Renewal lease unavailable".to_string())) + })?; + let reservation = InvocationReplayCache::reservation( + &admitted, + config.invocation.max_lifetime_secs, + ); + let stamp = tinycloud + .issue_renewal_stamp(tinycloud_core::renewal::StampIssue { + invocation: admitted.invocation().0.clone(), + original_cid, + lease_cid: lease.lease_cid, + replay_hash: reservation.content_hash, + replay_expires_at: reservation.expires_at.unix_timestamp(), + }) + .await + .map_err(|error| map_renewal_error(error, true))?; + let value = serde_json::json!({ + "stampCid": stamp.cid, + "leaseCid": stamp.lease_cid, + "generation": stamp.generation, + "previousStampCid": stamp.previous_stamp_cid, + "newEnd": stamp.new_end, + "signedBytes": encode_config(stamp.signed_bytes, URL_SAFE_NO_PAD), + "signature": encode_config(stamp.signature, URL_SAFE_NO_PAD), + }); + if let Some(timer) = timer { + timer.observe_duration(); + } + return Ok(DataOut::One(InvOut(InvocationOutcome::RenewalStamp(value)))); + } + // 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 +5230,68 @@ 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()); + } + + #[tokio::test] + async fn renewal_rejects_any_request_body() { + use tokio::io::{duplex, AsyncWriteExt}; + + assert!(require_empty_renewal_body(DataIn::None).await.is_ok()); + let (mut writer, body) = duplex(16); + writer.write_all(b"unexpected").await.unwrap(); + drop(writer); + assert_eq!( + require_empty_renewal_reader(body).await.unwrap_err().0, + Status::BadRequest + ); + let (writer, empty) = duplex(16); + drop(writer); + assert!(require_empty_renewal_reader(empty).await.is_ok()); + } + + #[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 +8035,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 +8051,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( diff --git a/tinycloud-node-server/src/routes/node_keys.rs b/tinycloud-node-server/src/routes/node_keys.rs index 493efce98..883f4d417 100644 --- a/tinycloud-node-server/src/routes/node_keys.rs +++ b/tinycloud-node-server/src/routes/node_keys.rs @@ -22,29 +22,30 @@ 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 { + let (renewer_did, renewer_epoch, renewer_public_key) = + tinycloud_core::renewal::renewal_identity(secret); 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, } } } @@ -92,15 +93,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 +130,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] From 0f607ecff4d57c7c7043323db4477d3f8456173f Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:48:57 +0000 Subject: [PATCH 02/12] fix: align renewal runtime with frozen contract --- docs/runbooks/renewal-leases.md | 9 + tinycloud-core/src/auth_graph.rs | 79 +- tinycloud-core/src/db.rs | 170 +++- .../m20261112_000000_renewal_identity.rs | 44 + tinycloud-core/src/migrations/mod.rs | 2 + tinycloud-core/src/models/delegation.rs | 8 +- tinycloud-core/src/models/revocation.rs | 25 +- tinycloud-core/src/renewal.rs | 919 ++++++++++++++---- tinycloud-node-server/src/lib.rs | 42 +- tinycloud-node-server/src/renewal.rs | 175 +++- tinycloud-node-server/src/routes/mod.rs | 227 ++--- tinycloud-node-server/src/routes/node_keys.rs | 10 +- .../tests/renewal_lease_v1_vectors.rs | 39 + .../tests/renewal_stamp_v1_vectors.rs | 49 +- 14 files changed, 1362 insertions(+), 436 deletions(-) create mode 100644 docs/runbooks/renewal-leases.md create mode 100644 tinycloud-core/src/migrations/m20261112_000000_renewal_identity.rs diff --git a/docs/runbooks/renewal-leases.md b/docs/runbooks/renewal-leases.md new file mode 100644 index 000000000..b7d6961a7 --- /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/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 495ca9ac8..f84083380 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -251,7 +251,7 @@ fn snapshot_branch(kind: i32, fields: [SimpleExpr; N]) -> Select query.expr_as(expr, Alias::new(*name)); } if N == 13 { - let null = || Expr::cust("NULL").into(); + let null = || Expr::cust("NULL"); let extra: [SimpleExpr; 18] = if kind == 4 { [ Expr::col(( @@ -282,7 +282,7 @@ fn snapshot_branch(kind: i32, fields: [SimpleExpr; N]) -> Select 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").into(), + Expr::cust("FALSE"), Expr::col((Alias::new("renewal_stamp"), Alias::new("generation"))).into(), ] } else { @@ -585,8 +585,7 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { 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})" - )) - .into(); + )); let mut renewal_rows = snapshot_branch( 5, [ @@ -765,21 +764,23 @@ fn resolve_effective_end( || candidate.end > latest_allowed || used.contains(&index) || crate::renewal::verify_snapshot_stamp( - &candidate.signed_bytes, - &candidate.signature, - &candidate.stamp, - &candidate.original, - &candidate.lease, - candidate.generation, - &candidate.previous, - candidate.end, - &candidate.did, - candidate.epoch, - &candidate.audience, - stamp_verifier_now, - candidate.period, - candidate.lease_expiry, - &ancestor_ends, + 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() { @@ -912,7 +913,7 @@ impl AuthGraphSnapshot { }; let state: String = row.try_get("", "lease_state")?; let lease_expiry: time::OffsetDateTime = row.try_get("", "lease_expiry")?; - if !matches!(state.as_str(), "active" | "stopped") + if state != "active" || lease_expiry.unix_timestamp() <= verifier_now || row.try_get::("", "lease_revoked")? || row.try_get::("", "renewer_did")? != renewer_did @@ -1000,23 +1001,8 @@ impl AuthGraphSnapshot { .values() .filter(|rows| rows[0].state == "active") .collect(); - let current = if active.len() == 1 { - Some(active[0]) - } else if active.is_empty() { - let stopped: Vec<_> = leases - .values() - .filter(|rows| rows[0].state == "stopped") - .collect(); - if stopped.len() == 1 { - Some(stopped[0]) - } else { - None - } - } else { - None - }; - if let Some(rows) = current { - candidate_map.insert(node, rows.clone()); + if active.len() == 1 { + candidate_map.insert(node, active[0].clone()); } } let mut renewal_effective_ends = HashMap::new(); @@ -1190,6 +1176,7 @@ mod tests { utc.date().with_time(utc.time()) } + #[allow(clippy::too_many_arguments)] async fn insert_snapshot_renewal( db: &DatabaseConnection, original: Hash, @@ -1203,7 +1190,8 @@ mod tests { 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()); + 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), @@ -1240,7 +1228,7 @@ mod tests { } 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,'stopped',0,'did:key:actor',$3,$4,$5,$6,5000,$7)", + "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)))), @@ -1275,7 +1263,7 @@ mod tests { 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,0,$4,$5,$6,$7,$8,$9)", + "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)))), @@ -1313,10 +1301,7 @@ mod tests { ) .await .unwrap(); - assert!( - 2_100 < 2_150, - "the linked predecessor is expired at verification time" - ); + // 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); @@ -1392,9 +1377,7 @@ mod tests { "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:did:key:other:files/kv".to_owned(), - ))), + 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!({})))), ], @@ -1413,7 +1396,7 @@ mod tests { .await .unwrap(); let capability = crate::util::Capability { - resource: "tinycloud:did:key:other:files/kv/doc".parse().unwrap(), + resource: "tinycloud:key:actor:files/kv/doc".parse().unwrap(), ability: "tinycloud.kv/get".to_owned().try_into().unwrap(), caveats: Caveats::default(), }; diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index ecdc5ee36..46c9de864 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -584,7 +584,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 } @@ -596,7 +599,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. @@ -608,6 +614,11 @@ where } delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; tx.commit().await?; + if let Some((seed, _)) = secrets.renewal_seed().zip(secrets.renewal_node_did()) { + crate::renewal::initialize_renewal_identity(&pool, seed) + .await + .map_err(|error| DbErr::Custom(error.to_string()))?; + } 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()); @@ -692,6 +703,51 @@ 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 roots = [target]; + let snapshot = match (self.secrets.renewal_node_did(), self.secrets.renewal_seed()) { + (Some(stable_node_did), Some(seed)) => { + let (renewer_did, epoch, _) = + crate::renewal::current_renewal_identity_from_seed(&self.conn, seed).await?; + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &self.conn, + &roots, + &stable_node_did, + &renewer_did, + 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(&target)) + } } impl SpaceDatabase { @@ -2260,7 +2316,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); } @@ -2328,12 +2387,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 @@ -2847,16 +2909,25 @@ where let auth_graph_result = match (self.secrets.renewal_node_did(), self.secrets.renewal_seed()) { (Some(stable_node_did), Some(seed)) => { - let (renewer_did, epoch, _) = - crate::renewal::renewal_identity_from_seed(seed); - crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( - &tx, - &roots, - &stable_node_did, - &renewer_did, - epoch, - now.unix_timestamp(), - ) + async { + let (renewer_did, epoch, _) = + crate::renewal::current_renewal_identity_from_seed(&tx, seed) + .await + .map_err(|error| { + revocation::ChainTraversalError::Db(DbErr::Custom( + error.to_string(), + )) + })?; + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &tx, + &roots, + &stable_node_did, + &renewer_did, + epoch, + now.unix_timestamp(), + ) + .await + } .await } _ => crate::auth_graph::AuthGraphSnapshot::load(&tx, &roots).await, @@ -3245,16 +3316,27 @@ where let graph_result = match (self.secrets.renewal_node_did(), self.secrets.renewal_seed()) { (Some(stable_node_did), Some(seed)) => { - let (renewer_did, epoch, _) = - crate::renewal::renewal_identity_from_seed(seed); - crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( - &self.conn, - &roots, - &stable_node_did, - &renewer_did, - epoch, - now.unix_timestamp(), - ) + async { + let (renewer_did, epoch, _) = + crate::renewal::current_renewal_identity_from_seed( + &self.conn, seed, + ) + .await + .map_err(|error| { + revocation::ChainTraversalError::Db(DbErr::Custom( + error.to_string(), + )) + })?; + crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( + &self.conn, + &roots, + &stable_node_did, + &renewer_did, + epoch, + now.unix_timestamp(), + ) + .await + } .await } _ => crate::auth_graph::AuthGraphSnapshot::load(&self.conn, &roots).await, @@ -4024,14 +4106,14 @@ pub(crate) async fn transact( 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 = - secrets - .renewal_node_did() - .zip(renewal_seed) - .map(|(stable_node_did, seed)| { - let (renewer_did, epoch, _) = crate::renewal::renewal_identity_from_seed(seed); - (stable_node_did, renewer_did, epoch) - }); + let renewal_identity = match (secrets.renewal_node_did(), renewal_seed) { + (Some(stable_node_did), Some(seed)) => { + let (renewer_did, epoch, _) = + crate::renewal::current_renewal_identity_from_seed(db, seed).await?; + Some((stable_node_did, renewer_did, epoch)) + } + _ => None, + }; let mut renewal_lease_events = Vec::new(); for (_, event) in &event_hashes { if let Event::Delegation(lease) = event { @@ -4067,9 +4149,19 @@ pub(crate) async fn transact( registered.insert(*hash, reg); } } - if let (Some(seed), Some((stable_node_did, _, _))) = (renewal_seed, renewal_identity.as_ref()) { + 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, stable_node_did, encryption).await?; + crate::renewal::register_lease( + db, + lease, + seed, + *renewer_epoch, + stable_node_did, + encryption, + ) + .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 000000000..578784c71 --- /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 73529f0a7..01ecaa496 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -35,6 +35,7 @@ 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; @@ -78,6 +79,7 @@ impl MigratorTrait for Migrator { 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 52b82be45..ff1a5c511 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -1117,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()); @@ -2004,13 +2005,14 @@ mod tests { 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, 0, &lease_cid, &end_text) + .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',0,'did:key:other',$3,$4,$5,$6,2592000,$7)", + "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)))), @@ -2023,7 +2025,7 @@ mod tests { )).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,0,$4,$5,$6,$7,$8,$9)", + "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)))), diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index 48a4a70fb..90bcf5d68 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -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, @@ -529,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())); @@ -537,7 +557,6 @@ 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( @@ -1269,7 +1288,7 @@ mod tests { .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',0,'did:key:owner','did:key:renewer',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,2592000,$3)", + "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()))), @@ -1308,7 +1327,7 @@ mod tests { ) .await .unwrap(); - assert!(!matches!(decision, ControlProofDecision::Scoped(_))); + assert!(matches!(decision, ControlProofDecision::Denied)); } } diff --git a/tinycloud-core/src/renewal.rs b/tinycloud-core/src/renewal.rs index 6adcc150a..4cc3f6f5a 100644 --- a/tinycloud-core/src/renewal.rs +++ b/tinycloud-core/src/renewal.rs @@ -7,7 +7,6 @@ use time::OffsetDateTime; use tinycloud_auth::ipld_core::cid::Cid; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use serde_json::json; use thiserror::Error; pub const STAMP_DOMAIN: &str = "tinycloud/node/renewal-stamp/v1"; @@ -73,6 +72,7 @@ pub struct LeaseRecord { pub renewer_did: String, pub renewer_epoch: u64, pub lease_expiry: i64, + pub period_seconds: u64, pub state: String, pub generation: u64, } @@ -156,35 +156,54 @@ pub fn verify_stamp_signature( 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( - signed_bytes: &[u8], - signature_bytes: &[u8], - expected_stamp_cid: &str, - expected_original_cid: &str, - expected_lease_cid: &str, - generation: u64, - previous_stamp_cid: &str, - new_end: i64, - node_did: &str, - renewer_epoch: u64, - audience_public_key: &[u8; 32], - verifier_now: i64, - period: i64, - lease_expiry: i64, - ancestor_ends: &[i64], + 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: serde_json::Value = serde_ipld_dagcbor::from_slice(signed_bytes) + 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 - .get("unsigned") - .ok_or(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)?; @@ -192,30 +211,16 @@ pub(crate) fn verify_snapshot_stamp( let end_string = unix_time(new_end)? .format(&time::format_description::well_known::Rfc3339) .map_err(|_| RenewalStoreError::InvalidInput)?; - if signed.as_object().is_none_or(|map| map.len() != 2) - || unsigned.as_object().is_none_or(|map| map.len() != 9) - || signed.get("domain").and_then(serde_json::Value::as_str) != Some(STAMP_DOMAIN) - || unsigned.get("type").and_then(serde_json::Value::as_str) != Some("TinyCloudRenewalStamp") - || unsigned.get("version").and_then(serde_json::Value::as_u64) != Some(1) - || unsigned - .get("originalCid") - .and_then(serde_json::Value::as_str) - != Some(&expected_original_cid) - || unsigned.get("leaseCid").and_then(serde_json::Value::as_str) != Some(&expected_lease_cid) - || unsigned - .get("leaseGeneration") - .and_then(serde_json::Value::as_u64) - != Some(generation) - || unsigned - .get("previousStampCid") - .and_then(serde_json::Value::as_str) - != Some(&previous_stamp_cid) - || unsigned.get("nodeDid").and_then(serde_json::Value::as_str) != Some(node_did) - || unsigned - .get("renewerEpoch") - .and_then(serde_json::Value::as_u64) - != Some(renewer_epoch) - || unsigned.get("newEnd").and_then(serde_json::Value::as_str) != Some(&end_string) + 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); @@ -224,6 +229,35 @@ pub(crate) fn verify_snapshot_stamp( 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, @@ -231,12 +265,9 @@ pub struct RenewalSigner { } impl RenewalSigner { - pub fn from_seed(seed: [u8; 32], node_did: String) -> Self { - let key = SigningKey::from_bytes(&seed); - let digest = blake3::hash(&key.verifying_key().to_bytes()); - let renewer_epoch = u64::from_be_bytes(digest.as_bytes()[..8].try_into().unwrap()); + pub fn from_seed(seed: [u8; 32], node_did: String, renewer_epoch: u64) -> Self { Self { - key, + key: SigningKey::from_bytes(&seed), node_did, renewer_epoch, } @@ -258,34 +289,43 @@ impl RenewalSigner { (Vec, Vec, String), serde_ipld_dagcbor::EncodeError, > { - let unsigned = json!({ - "type": "TinyCloudRenewalStamp", - "version": 1, - "originalCid": original_cid, - "leaseCid": lease_cid, - "leaseGeneration": lease_generation, - "previousStampCid": previous_stamp_cid, - "nodeDid": self.node_did, - "renewerEpoch": self.renewer_epoch, - "newEnd": new_end, - }); - let signed = json!({"domain": STAMP_DOMAIN, "unsigned": unsigned}); + 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, its stable key epoch, and raw public key. + +/// Return the dedicated node renewer DID, initial epoch, and raw public key. pub fn renewal_identity(secret: &StaticSecret) -> (String, u64, [u8; 32]) { - renewal_identity_from_seed(secret.derive_key(RENEWER_KEY_CONTEXT)) + 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 digest = blake3::hash(&bytes); - let epoch = u64::from_be_bytes(digest.as_bytes()[..8].try_into().unwrap()); 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()); @@ -296,25 +336,109 @@ pub fn is_renewer_did(secret: &StaticSecret, did: &str) -> bool { renewal_identity(secret).0 == did } -/// The control capability is exact: no prefix, alternate service or resource form. 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, +) -> Result, RenewalStoreError> { + lease_for_original_at(db, original_cid, OffsetDateTime::now_utc().unix_timestamp()).await +} + +async fn lease_for_original_at( + db: &C, + original_cid: &str, + verifier_now: i64, ) -> Result, RenewalStoreError> { let (original_cid, original) = canonical_cid(original_cid)?; if is_revoked_chain(db, &original).await? { return Ok(None); } - let now = db_datetime(OffsetDateTime::now_utc().unix_timestamp())?; + let now = db_datetime(verifier_now)?; let rows = db.query_all(Statement::from_sql_and_values( db.get_database_backend(), - "SELECT lease_cid, grantor, renewer_did, renewer_epoch, lease_expiry, state, generation FROM renewal_lease WHERE original_canonical = $1 AND state = 'active' AND lease_expiry > $2 ORDER BY lease_cid", + "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 = (SELECT renewer_did FROM renewal_identity WHERE id = 1) AND renewer_epoch = (SELECT epoch FROM renewal_identity WHERE id = 1) ORDER BY lease_cid", [ Value::Bytes(Some(Box::new(Vec::::from(original)))), Value::TimeDateTime(Some(Box::new(now))), @@ -338,19 +462,57 @@ pub async fn lease_for_original( 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 the active leases belonging to the node's persisted current renewer epoch. +pub async fn active_leases_for_current_epoch( + db: &C, +) -> Result, RenewalStoreError> { + let backend = db.get_database_backend(); + let now = db_datetime(OffsetDateTime::now_utc().unix_timestamp())?; + 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 = (SELECT renewer_did FROM renewal_identity WHERE id = 1) AND renewer_epoch = (SELECT epoch FROM renewal_identity WHERE id = 1) ORDER BY original_canonical", + [Value::TimeDateTime(Some(Box::new(now)))], + )) + .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(db, &original_cid).await? { + leases.push(lease); + } + } + Ok(leases) +} -/// Authorize a dedicated `/invoke` control request without the generic graph -/// authorization or the space-owner shortcuts. pub async fn authorize_renewal_invocation( db: &C, invocation: &crate::util::InvocationInfo, original_cid: &str, +) -> Result<(), RenewalStoreError> { + authorize_renewal_invocation_at( + db, + invocation, + original_cid, + OffsetDateTime::now_utc().unix_timestamp(), + ) + .await +} + +async fn authorize_renewal_invocation_at( + db: &C, + invocation: &crate::util::InvocationInfo, + original_cid: &str, + verifier_now: i64, ) -> Result<(), RenewalStoreError> { use crate::models::revocation::{ControlAction, ControlProofDecision}; if invocation.capabilities.len() != 1 @@ -363,12 +525,17 @@ pub async fn authorize_renewal_invocation( { return Err(RenewalStoreError::Unauthorized); } - let lease = lease_for_original(db, original_cid) + let lease = lease_for_original_at(db, original_cid, verifier_now) .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); @@ -377,12 +544,13 @@ pub async fn authorize_renewal_invocation( .await? .ok_or(RenewalStoreError::Unauthorized)?; - match crate::models::revocation::control_proof_decision( + match crate::models::revocation::control_proof_decision_at( db, &invocation.invoker, &invocation.parents, ControlAction::Renew, &target, + unix_time(verifier_now)?, ) .await? { @@ -537,8 +705,30 @@ 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)?; @@ -613,13 +803,13 @@ pub(crate) async fn register_lease( { return Err(RenewalStoreError::InvalidInput); } - let (renewer_did, renewer_epoch, audience_public_key) = - renewal_identity_from_seed(renewal_seed); + 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 now = OffsetDateTime::now_utc().unix_timestamp(); + let lease_horizon = now .checked_add(MAX_LEASE_SECONDS) .ok_or(RenewalStoreError::InvalidInput)?; @@ -705,17 +895,22 @@ pub(crate) async fn register_lease( .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", - [Value::Bytes(Some(Box::new(Vec::::from(original))))], - )).await?; + 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? { - return Err(RenewalStoreError::LeaseUnavailable); + continue; } let old_expiry: OffsetDateTime = old.try_get("", "lease_expiry")?; if old_expiry.unix_timestamp() <= now { @@ -737,7 +932,7 @@ pub(crate) async fn register_lease( } 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', 0, $3, $4, $5, $6, $7, $8, $9)", + "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)))), @@ -749,7 +944,8 @@ pub(crate) async fn register_lease( Value::BigInt(Some(period)), Value::Bytes(Some(Box::new(audience_public_key.to_vec()))), ], - )).await?; + )) + .await?; Ok(()) } @@ -757,13 +953,48 @@ pub(crate) async fn issue_stamp( conn: &C, request: StampIssue, secret: &StaticSecret, +) -> Result { + issue_stamp_at( + conn, + request, + secret, + OffsetDateTime::now_utc().unix_timestamp(), + ) + .await +} + +async fn issue_stamp_at( + conn: &C, + request: StampIssue, + secret: &StaticSecret, + 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?; - authorize_renewal_invocation(&tx, &request.invocation, &request.original_cid).await?; - issue_stamp_in_transaction(&tx, request, secret).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, now) + .await?; + issue_stamp_in_transaction(&tx, request, secret, now).await } .await; match result { @@ -782,13 +1013,10 @@ async fn issue_stamp_in_transaction( tx: &C, request: StampIssue, secret: &StaticSecret, + now: i64, ) -> Result { let (original_cid, original) = canonical_cid(&request.original_cid)?; let (lease_cid, lease) = canonical_cid(&request.lease_cid)?; - let now = OffsetDateTime::now_utc().unix_timestamp(); - if request.replay_expires_at <= now { - return Err(RenewalStoreError::InvalidInput); - } let backend = tx.get_database_backend(); let lease_row = tx.query_one(Statement::from_sql_and_values( backend, @@ -800,7 +1028,7 @@ async fn issue_stamp_in_transaction( )).await?.ok_or(RenewalStoreError::LeaseUnavailable)?; let state: String = lease_row.try_get("", "state")?; let generation: i64 = lease_row.try_get("", "generation")?; - if generation < 0 { + if generation <= 0 { return Err(RenewalStoreError::InvalidInput); } let renewer_did: String = lease_row.try_get("", "renewer_did")?; @@ -809,7 +1037,7 @@ async fn issue_stamp_in_transaction( 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_did, current_epoch, _) = renewal_identity(secret); + let (current_did, current_epoch, _) = current_renewal_identity(tx, secret).await?; if state != "active" || now >= lease_expiry.unix_timestamp() { return Err(RenewalStoreError::LeaseUnavailable); } @@ -847,11 +1075,14 @@ async fn issue_stamp_in_transaction( return Err(RenewalStoreError::LeaseUnavailable); } let new_end = new_end(now, period, lease_expiry.unix_timestamp(), &ancestor_ends)?; - if new_end <= now { + if new_end <= current_original_end { return Err(RenewalStoreError::LeaseUnavailable); } - let signer = - RenewalSigner::from_seed(secret.derive_key(RENEWER_KEY_CONTEXT), secret.node_did()); + let signer = RenewalSigner::from_seed( + secret.derive_key(RENEWER_KEY_CONTEXT), + secret.node_did(), + u64::try_from(renewer_epoch).map_err(|_| RenewalStoreError::InvalidInput)?, + ); let new_end_string = unix_time(new_end)? .format(&time::format_description::well_known::Rfc3339) .map_err(|_| RenewalStoreError::InvalidInput)?; @@ -876,22 +1107,7 @@ async fn issue_stamp_in_transaction( lease_expiry.unix_timestamp(), &ancestor_ends, )?; - if let Err(error) = tx - .execute(Statement::from_sql_and_values( - 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()); - } + let stamp_hash = canonical_cid(&cid)?.1; tx.execute(Statement::from_sql_and_values( backend, @@ -992,7 +1208,7 @@ pub async fn effective_end( ) -> Result, RenewalStoreError> { let (_, original) = canonical_cid(original_cid)?; let (_, lease) = canonical_cid(lease_cid)?; - let (renewer_did, renewer_epoch, _) = renewal_identity(secret); + let (renewer_did, renewer_epoch, _) = current_renewal_identity(db, secret).await?; let snapshot = load_renewal_snapshot( db, &[original, lease], @@ -1064,7 +1280,9 @@ pub async fn renewal_history( invoker: &str, proofs: &[Cid], ) -> Result, RenewalStoreError> { - let (_, original_id) = canonical_cid(original_cid)?; + 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); } @@ -1101,10 +1319,69 @@ pub async fn renewal_history( 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() { @@ -1124,7 +1401,7 @@ mod tests { #[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()); + 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( @@ -1139,7 +1416,7 @@ mod tests { verify_stamp_signature(&bytes, &signature, &signer.public_key()), Ok(()) ); - let node_identity_key = RenewalSigner::from_seed([8; 32], stable_node_did.clone()); + 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()), @@ -1151,46 +1428,46 @@ mod tests { 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()); + 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( - &bytes, - &signature, - &stamp, - original, - lease, - 0, - lease, - 2000, - &signer.node_did, - signer.renewer_epoch, - &signer.public_key(), - 1000, - 2000, - 4000, - &[3000], - ) + 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( - &bytes, - &signature, - &stamp, - original, - lease, - 0, - lease, - 2000, - "did:key:wrong-node", - signer.renewer_epoch, - &signer.public_key(), - 1000, - 2000, - 4000, - &[3000], - ), + 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) )); } @@ -1199,28 +1476,28 @@ mod tests { 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()); + 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( - bytes, - signature, - cid, - original, - lease, - 0, - previous, - end, - &signer.node_did, - signer.renewer_epoch, - &signer.public_key(), - 2_000, - 5_000, - 9_000, - &[8_000], - ) + 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(); @@ -1228,19 +1505,62 @@ mod tests { .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(); - assert!(3_000 > 2_000); + // The tip ends after the verifier time. } #[test] - fn renewer_epoch_is_restart_stable_and_rotates_with_secret() { + 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).1, renewal_identity(&rotated).1); + 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"; @@ -1283,22 +1603,46 @@ mod tests { .await .unwrap(); crate::migrations::Migrator::up(&db, None).await.unwrap(); - let secret = StaticSecret::new(vec![4; 32]).unwrap(); - let (renewer_did, renewer_epoch, audience) = renewal_identity(&secret); + 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 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 now = OffsetDateTime::now_utc().unix_timestamp(); - let end = now + 1000; - let grantor = "did:key:renewal-owner"; + 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.to_owned(), + grantor.clone(), renewer_did.clone(), "did:key:child".to_owned(), - "did:key:holder".to_owned(), + original_info.delegate.clone(), ] { tx.execute(Statement::from_sql_and_values( DatabaseBackend::Sqlite, @@ -1308,7 +1652,11 @@ mod tests { .await .unwrap(); } - let insert_delegation = |id: Hash, delegator: String, delegatee: String, expiry: i64| { + 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)", @@ -1317,31 +1665,34 @@ mod tests { 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(vec![1]))), + Value::Bytes(Some(Box::new(serialization))), ], ) }; tx.execute(insert_delegation( ancestor, - grantor.to_owned(), + grantor.clone(), "did:key:child".to_owned(), end, + vec![1], )) .await .unwrap(); tx.execute(insert_delegation( original, - grantor.to_owned(), - "did:key:holder".to_owned(), - now + 5000, + grantor.clone(), + original_info.delegate.clone(), + original_expiry, + original_serialization.clone(), )) .await .unwrap(); tx.execute(insert_delegation( lease, - grantor.to_owned(), - renewer_did.clone(), - now + 6000, + grantor.clone(), + lease_info.delegate.clone(), + lease_expiry, + lease_serialization.clone(), )) .await .unwrap(); @@ -1378,16 +1729,16 @@ mod tests { .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',0,$3,$4,$5,$6,$7,$8,$9)", + "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(now + 6000).unwrap()))), - Value::TimeDateTime(Some(Box::new(db_datetime(now + 5000).unwrap()))), - Value::BigInt(Some(10_000)), + 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(); @@ -1404,16 +1755,16 @@ mod tests { .unwrap(); assert_eq!(stored_audience, audience); assert_eq!( - RenewalSigner::from_seed(secret.derive_key(RENEWER_KEY_CONTEXT), secret.node_did()) - .public_key(), + RenewalSigner::from_seed( + secret.derive_key(RENEWER_KEY_CONTEXT), + secret.node_did(), + renewer_epoch, + ) + .public_key(), audience ); tx.commit().await.unwrap(); - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../tinycloud-node-server/tests/fixtures/renewal-lease-v1.json" - )) - .unwrap(); let invocation_token = tinycloud_auth::authorization::TinyCloudInvocation::decode( fixture["originalToken"].as_str().unwrap(), ) @@ -1436,16 +1787,25 @@ mod tests { let mut wrong_caller = request.invocation.clone(); wrong_caller.invoker = "did:key:attacker".to_owned(); assert!(matches!( - authorize_renewal_invocation(&db, &wrong_caller, original_cid).await, + authorize_renewal_invocation_at(&db, &wrong_caller, original_cid, 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, now).await, Err(RenewalStoreError::Unauthorized) )); - authorize_renewal_invocation(&db, &request.invocation, original_cid) + authorize_renewal_invocation_at(&db, &request.invocation, original_cid, now) + .await + .unwrap(); + let issued = issue_stamp_at(&db, request.clone(), &secret, now) .await .unwrap(); - let issued = issue_stamp(&db, request.clone(), &secret).await.unwrap(); assert!(issued.new_end <= end); + assert!(issued.new_end > original_expiry); assert!(matches!( - issue_stamp(&db, request, &secret).await, + issue_stamp_at(&db, request.clone(), &secret, now).await, Err(RenewalStoreError::Replay) )); @@ -1455,6 +1815,126 @@ mod tests { .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_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!(lease_for_original_at(&db, original_cid, now) + .await + .unwrap() + .is_none()); + assert!(issue_stamp_at(&db, request, &secret, 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) + .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, @@ -1536,7 +2016,7 @@ mod tests { } 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',0,$3,$4,$5,$6,$7,10000,$8)", + "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)))), @@ -1572,14 +2052,19 @@ mod tests { .unwrap() .format(&time::format_description::well_known::Rfc3339) .unwrap(); - let signer = RenewalSigner::from_seed(renewal_seed, secret.node_did()); + let signer = RenewalSigner::from_seed(renewal_seed, secret.node_did(), renewer_epoch); let (signed_bytes, signature, _) = signer - .sign_stamp(original_cid, lease_cid, 0, lease_cid, &renewed_end_text) + .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,0,$4,$5,$6,$7,$8,$9)", + "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)))), @@ -1588,12 +2073,36 @@ mod tests { 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(signature))), + 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, diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 8ba639dfe..7292f99c1 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -762,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( @@ -864,6 +863,10 @@ 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_core::renewal::current_renewal_identity(tinycloud.connection(), &key_setup) + .await?; + 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, )); @@ -1059,7 +1062,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(); @@ -1090,6 +1094,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 index 33d9714bf..4b954f984 100644 --- a/tinycloud-node-server/src/renewal.rs +++ b/tinycloud-node-server/src/renewal.rs @@ -3,6 +3,179 @@ pub use tinycloud_core::renewal::{ RenewalError, RenewalSigner, RENEWER_KEY_CONTEXT, STAMP_DOMAIN, }; +use anyhow::{anyhow, Result}; +use tinycloud_auth::{ + authorization::{make_invocation_from_uris, InvocationOptions, TinyCloudInvocation}, + resolver::DID_METHODS, + ssi::jwk::{Algorithm, Base64urlUInt, OctetParams, Params, JWK}, +}; +use tinycloud_core::{ + events::Invocation, + keys::StaticSecret, + renewal::{self, LeaseRecord, RenewalStoreError, StampIssue}, +}; + +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 = DID_METHODS.generate(&jwk, "key")?.to_string(); + if did != secret.renewal_did() { + return Err(anyhow!("derived renewal DID mismatch")); + } + 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 leases = renewal::active_leases_for_current_epoch(conn).await?; + let (signer, verification_method) = renewal_invocation_signer(secret)?; + for lease in leases { + if lease.state != "active" || lease.renewer_did != secret.renewal_did() { + continue; + } + let Some(end) = + renewal::effective_end(conn, &lease.original_cid, &lease.lease_cid, now, secret) + .await? + else { + continue; + }; + if !due_for_renewal(now, end, lease.period_seconds) { + continue; + } + 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, + }; + if let Err(error) = tinycloud.issue_renewal_stamp(issue).await { + if !matches!( + &error, + RenewalStoreError::LeaseUnavailable | RenewalStoreError::IdentityEpoch + ) { + ::tracing::warn!( + event = "renewal_scheduler_issue_failed", + error = %error, + "internal renewal scheduler did not issue a stamp" + ); + } + } + } + Ok(()) +} + +#[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 (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: secret.renewal_did(), + 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, 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)); + } +} + #[cfg(test)] mod tests { use super::{ @@ -89,7 +262,7 @@ mod tests { #[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()); + 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(); diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index 3ab614ad5..fa1045ec7 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -603,6 +603,7 @@ pub struct DelegationStatusResponse { pub active: bool, pub revoked: bool, pub expired: bool, + pub effective_expiry: Option, pub renewal_history: Vec, } @@ -610,6 +611,7 @@ impl DelegationStatusResponse { fn from_status( cid: String, status: DelegationStatus, + effective_expiry: Option, renewal_history: Vec, ) -> Self { let status_name = match status { @@ -625,6 +627,7 @@ impl DelegationStatusResponse { active: status == DelegationStatus::Active, revoked: status == DelegationStatus::Revoked, expired: status == DelegationStatus::Expired, + effective_expiry, renewal_history, } } @@ -637,6 +640,7 @@ impl DelegationStatusResponse { active: false, revoked: false, expired: false, + effective_expiry: None, renewal_history: Vec::new(), } } @@ -686,6 +690,13 @@ pub async fn delegation_status( .map_err(|_| (Status::ServiceUnavailable, "Status unavailable".to_string()))? { 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, @@ -715,6 +726,7 @@ pub async fn delegation_status( Json(DelegationStatusResponse::from_status( request.cid, status, + effective_expiry, history, )), )) @@ -1259,34 +1271,6 @@ fn kv_sync_target( )), } } -async fn require_empty_renewal_body(data: DataIn<'_>) -> Result<(), (Status, String)> { - match data { - DataIn::None => Ok(()), - DataIn::One(data) => require_empty_renewal_reader(data.open(1u8.bytes())).await, - DataIn::Many(_) => Err(( - Status::BadRequest, - "Renewal invocation must not include a body".to_string(), - )), - } -} - -async fn require_empty_renewal_reader( - mut reader: R, -) -> Result<(), (Status, String)> { - let mut byte = [0u8; 1]; - let read = reader - .read(&mut byte) - .await - .map_err(|error| (Status::BadRequest, error.to_string()))?; - if read == 0 { - Ok(()) - } else { - Err(( - Status::BadRequest, - "Renewal invocation must not include a body".to_string(), - )) - } -} /// Returns the original CID for the exact one-capability renewal control /// invocation, or rejects malformed renewal capability sets. @@ -1329,43 +1313,6 @@ fn renewal_target( Ok(Some(cid)) } -fn map_renewal_error( - error: tinycloud_core::renewal::RenewalStoreError, - write_may_have_applied: bool, -) -> InvokeError { - let (status, message) = match error { - tinycloud_core::renewal::RenewalStoreError::Unauthorized => ( - Status::Forbidden, - "Renewal invocation is not authorized".to_string(), - ), - tinycloud_core::renewal::RenewalStoreError::Revoked => ( - Status::Forbidden, - "Renewal delegation is revoked".to_string(), - ), - tinycloud_core::renewal::RenewalStoreError::LeaseUnavailable => { - (Status::Conflict, "Renewal lease is unavailable".to_string()) - } - tinycloud_core::renewal::RenewalStoreError::Replay => { - (Status::Conflict, "Renewal invocation replayed".to_string()) - } - tinycloud_core::renewal::RenewalStoreError::IdentityEpoch => ( - Status::Forbidden, - "Renewal identity has changed".to_string(), - ), - tinycloud_core::renewal::RenewalStoreError::InvalidInput - | tinycloud_core::renewal::RenewalStoreError::Invalid(_) => { - (Status::BadRequest, "Invalid renewal request".to_string()) - } - tinycloud_core::renewal::RenewalStoreError::Db(error) => database_failure( - Service::Other, - &error, - write_may_have_applied, - "Renewal unavailable".to_string(), - ), - }; - InvokeError::from((status, message)) -} - /// 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( @@ -2043,52 +1990,12 @@ async fn invoke_impl( .into()); } - if let Some(original_cid) = renewal_target(&admitted.invocation().0.capabilities)? { - require_empty_renewal_body(data).await?; - let original_cid = original_cid.to_string(); - tinycloud_core::renewal::authorize_renewal_invocation( - tinycloud.connection(), - &admitted.invocation().0, - &original_cid, - ) - .await - .map_err(|error| map_renewal_error(error, false))?; - let lease = tinycloud_core::renewal::lease_for_original( - tinycloud.connection(), - &original_cid, + if renewal_target(&admitted.invocation().0.capabilities)?.is_some() { + return Err(( + Status::Forbidden, + "Renewal is managed by the node scheduler".to_owned(), ) - .await - .map_err(|error| map_renewal_error(error, false))? - .ok_or_else(|| { - InvokeError::from((Status::Forbidden, "Renewal lease unavailable".to_string())) - })?; - let reservation = InvocationReplayCache::reservation( - &admitted, - config.invocation.max_lifetime_secs, - ); - let stamp = tinycloud - .issue_renewal_stamp(tinycloud_core::renewal::StampIssue { - invocation: admitted.invocation().0.clone(), - original_cid, - lease_cid: lease.lease_cid, - replay_hash: reservation.content_hash, - replay_expires_at: reservation.expires_at.unix_timestamp(), - }) - .await - .map_err(|error| map_renewal_error(error, true))?; - let value = serde_json::json!({ - "stampCid": stamp.cid, - "leaseCid": stamp.lease_cid, - "generation": stamp.generation, - "previousStampCid": stamp.previous_stamp_cid, - "newEnd": stamp.new_end, - "signedBytes": encode_config(stamp.signed_bytes, URL_SAFE_NO_PAD), - "signature": encode_config(stamp.signature, URL_SAFE_NO_PAD), - }); - if let Some(timer) = timer { - timer.observe_duration(); - } - return Ok(DataOut::One(InvOut(InvocationOutcome::RenewalStamp(value)))); + .into()); } // TC-732: `kv/sync` must be the invocation's only capability and @@ -5268,23 +5175,6 @@ mod tests { assert!(renewal_target(&[]).unwrap().is_none()); } - #[tokio::test] - async fn renewal_rejects_any_request_body() { - use tokio::io::{duplex, AsyncWriteExt}; - - assert!(require_empty_renewal_body(DataIn::None).await.is_ok()); - let (mut writer, body) = duplex(16); - writer.write_all(b"unexpected").await.unwrap(); - drop(writer); - assert_eq!( - require_empty_renewal_reader(body).await.unwrap_err().0, - Status::BadRequest - ); - let (writer, empty) = duplex(16); - drop(writer); - assert!(require_empty_renewal_reader(empty).await.is_ok()); - } - #[test] fn non_renewal_invocations_keep_generic_dispatch() { let mut capability = renewal_capability("urn:cid:not-a-cid"); @@ -8070,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 [ @@ -8246,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, @@ -11157,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 883f4d417..f7738f8ae 100644 --- a/tinycloud-node-server/src/routes/node_keys.rs +++ b/tinycloud-node-server/src/routes/node_keys.rs @@ -35,8 +35,12 @@ pub struct NodePublicKeys { impl NodePublicKeys { pub fn derive(secret: &StaticSecret) -> Self { - let (renewer_did, renewer_epoch, renewer_public_key) = - tinycloud_core::renewal::renewal_identity(secret); + 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( @@ -45,7 +49,7 @@ impl NodePublicKeys { ), renewer_did, renewer_public_key: encode_config(renewer_public_key, URL_SAFE_NO_PAD), - renewer_epoch, + renewer_epoch: epoch, } } } diff --git a/tinycloud-node-server/tests/renewal_lease_v1_vectors.rs b/tinycloud-node-server/tests/renewal_lease_v1_vectors.rs index 1f9757cff..07336d2f7 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 b24d03a4c..96b6b4c00 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"]); +} From 8097174768f235a764accf4d378d995d03b2b937 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:53:41 +0000 Subject: [PATCH 03/12] docs: restore frozen renewal wire contracts --- docs/specs/renewal-lease-v1.md | 2 +- docs/specs/renewal-stamp-v1.md | 30 ++++++++++++++---------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/specs/renewal-lease-v1.md b/docs/specs/renewal-lease-v1.md index 33e21ce34..96f709ae8 100644 --- a/docs/specs/renewal-lease-v1.md +++ b/docs/specs/renewal-lease-v1.md @@ -17,7 +17,7 @@ The original token is signed by the test owner and grants `tinycloud.kv/get` to The original and lease CIDs are CIDv1 raw (`0x55`) with Blake3-256 over the exact token bytes returned by `TinyCloudDelegation::decode`. The lease's resource is derived from the canonical CID of the signed original UCAN, not from a client-provided digest. The full lease bytes are the exact `leaseToken` string in the fixture encoded as UTF-8; the original's exact bytes are its `originalToken` string encoded as UTF-8. -The period in v1 is the owner's signed original grant `exp - nbf`. Only for a CACAO original, if `nbf` is absent, the signed issued-at value is the anchor; a UCAN without `nbf` is rejected. Missing expiry, missing CACAO anchors, malformed dates, or a non-positive period are rejected. This vector's original period is 30 days (`2,592,000` seconds). V1 has no lease period override. The owner-signed lease expiry is a separate upper bound: this vector's lease lifetime is 365 days (`31,536,000` seconds). Renewal issuance computes `newEnd = min(now + period, lease expiry, each ancestor's effective end)`. At issuance and stamp verification, `newEnd` must not exceed the lease expiry or any ancestor's effective end; at acceptance it must also satisfy `newEnd <= verifier_now + period`. The period is derived from signed claims, not a caller value. +The period in v1 is the owner's signed original UCAN `exp - nbf`; if `nbf` is absent, the signed issued-at value is the anchor. This vector's original period is 30 days (`2,592,000` seconds). V1 has no lease period override. The owner-signed lease expiry is a separate upper bound: this vector's lease lifetime is 365 days (`31,536,000` seconds). The test derives both values from the signed UCAN claims and checks that `newEnd = min(now + period, lease expiry)` respects the lease cap. The `firstStamp` entry is signed by the derived renewer key and pins canonical DAG-CBOR bytes, detached signature, and CID. Its `leaseCid` and non-null `previousStampCid` both equal the actual signed lease CID `bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq`; the test asserts both links. diff --git a/docs/specs/renewal-stamp-v1.md b/docs/specs/renewal-stamp-v1.md index 2fa67b39b..9681ad437 100644 --- a/docs/specs/renewal-stamp-v1.md +++ b/docs/specs/renewal-stamp-v1.md @@ -1,15 +1,15 @@ # Renewal stamp v1 wire contract -This file records the exact v1 signed-stamp representation pinned by fixture commit `377c0cd9269c982bb2e71b60a51bc77367718031`, the owner-signed lease and linked first stamp pinned by #546 commit `bd2f95ca02ff25f4c67890ad0770ff0451c25295`, and the Bobbie-reviewed design `1635acfc1c4d9f965a58101eb5f8a0dcb45f39ef0996225021597157ac757a53`. Runtime code must enforce the validity bounds below. +This file records the exact v1 signed-stamp representation pinned by fixture commit `377c0cd9269c982bb2e71b60a51bc77367718031`, based on main `d63d4c44ef274d24f67a7f889379226e4769e261`. The selected architecture is the Bobbie-reviewed design `1635acfc1c4d9f965a58101eb5f8a0dcb45f39ef0996225021597157ac757a53`. This specification freezes the wire contract only; it does not implement runtime renewal behavior. ## Renewal lease authority -A renewal lease is a real owner-signed UCAN/CACAO DAG delegation addressed to the node renewer DID. Its exact control capability is: +A renewal lease is an existing owner-authorized UCAN/CACAO delegation. This specification does **not** define a new lease serialization, lease signature format, or a real signed lease vector. Its exact control capability is: - Ability: `tinycloud.delegation/renew` - Resource: `urn:cid:` -The #546 fixture pins the signed lease token and CID. Its linked first stamp sets both `leaseCid` and `previousStampCid` to that real lease CID. The signed original grant supplies the renewal period; the owner-signed lease expiry is a separate upper bound. For UCAN, `nbf` is required. Only CACAO may use its signed issued-at claim when `nbf` is absent. +The CIDs named `originalCid` and `leaseCid` in the known-answer fixture are generated from synthetic DAG-CBOR placeholder artifacts. They test stamp encoding and the non-null root link only; they are not signed UCAN/CACAO delegation records and do not freeze the lease's actual bytes. ## Signed stamp bytes @@ -38,20 +38,18 @@ Encode with the repository's deterministic `serde_ipld_dagcbor` representation. Derive the renewer signing seed from the node's `StaticSecret` with the exact context bytes `tinycloud/node/renewer/v1`, using `StaticSecret::derive_key` (Blake3-256 of the static secret followed by the context). This is distinct from the node's identity-key context and storage-key generation. -Verification uses the public key for the renewer DID in the exact owner-signed lease audience; the stable `nodeDid` is not a signing key. A stamp is valid only when its signature verifies under that audience key and its original CID, lease CID, epoch, and previous-stamp link match the registered chain. At creation, `newEnd` is `min(now + signed original period, lease expiry, every ancestor's effective end)`. At creation and verification, `newEnd` MUST be no later than the lease expiry or any ancestor's effective end; at acceptance it MUST also be no later than verifier time plus the signed original period. +## Pinned vector -## Pinned linked lease and stamp vector +The synthetic fixture is `tinycloud-node-server/tests/fixtures/renewal-stamp-v1.json`; its assertions are in `tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs`. The fixture's fixed master-secret bytes (`42` repeated 32 times) are public test data only. -The signed lease, original grant, linked first stamp, and assertions are in `tinycloud-node-server/tests/fixtures/renewal-lease-v1.json` and `tinycloud-node-server/tests/renewal_lease_v1_vectors.rs`. The exact lease and first-stamp vectors were independently verified for #546 at `bd2f95ca02ff25f4c67890ad0770ff0451c25295`. +- Original placeholder CID: `bafkr4ifaiwrq52xkndrm7jbwqvywq3tkilydyxrj24oofckit6ink7f6hy` +- Lease placeholder CID and first `previousStampCid`: `bafkr4iaiwsgu3x5bvuigtms3hrlhrwpshbv5lxar5oj7uhh5dfg2sbcl4q` +- Stamp CID: `bafkr4ibtywvyzwld56wljgytygpis2dddualt6h6b343e52rzmsegyw5nq` +- Signer public key (hex): `49fd44f3fe8930b0e89f61c51333195578deacaf22105a78f647d8c05afc7c5d` +- Detached signature (hex): `34caf6cc60615a4c0d4f4e934c0263dde1c17fdd5becfa0f693337987b0250f0b430d7fe4518db41a882cc26ee979603736b883798fb83bbc95fb61af077020d` -- Original CID: `bafkr4ibdtwxhoauccwuknmf7cd5m3o74rwek7al4wgs33ecoygubpmour4` -- Lease CID and first `previousStampCid`: `bafkr4ignh3iowa5gysgegtuosa62pdkw6mpvrhsr3umjey5x2zo4fvjgqq` -- First stamp CID: `bafkr4iewc5ewhaccw5xh3cvxokvkehjvf76atyhvftq4i4eq7ayneimt3e` -- Signer DID: the dedicated renewer DID in the lease audience, distinct from the stable node identity DID. +Exact canonical DAG-CBOR bytes (hex): -The separate `renewal-stamp-v1.json` vector remains a stamp-only canonical-byte test. It is not the linked lease vector. -The linked vector's exact DAG-CBOR bytes, detached signature, signer key and recomputed CIDs are pinned in `renewal-lease-v1.json`; the stamp-only fixture is not authoritative for lease linkage. - -## Current node slice boundary - -The current node slice implements renewal issuance/use with existing revoke and status behavior. It does not expose STOP or re-enable operations; those are deferred until their owner-authenticated exact-CID control contract is separately frozen and reviewed. Until then, the owner's only way to turn renewal off is to revoke the lease. Revocation is irreversible and immediately invalidates renewed access; it cannot be re-enabled. This delivery boundary does not change the D13/D18 target behavior. +```text +a266646f6d61696e781f74696e79636c6f75642f6e6f64652f72656e6577616c2d7374616d702f763168756e7369676e6564a964747970657554696e79436c6f756452656e6577616c5374616d70666e6577456e6474323033302d30312d30325430333a30343a30355a676e6f646544696478386469643a6b65793a7a364d6b7058766555796a7043446536503833724e5971415a6d6f546339716f704a364742755251526f747a337a46376776657273696f6e01686c65617365436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c34716b6f726967696e616c436964783b6261666b7234696661697772713532786b6e64726d376a6277717679777133746b696c79647978726a32346f6f66636b697436696e6b37663668796c72656e6577657245706f6368076f6c6561736547656e65726174696f6e037070726576696f75735374616d70436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c3471 +``` From de6146255b7156909039fcff594ffec8a39ffbc4 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:57:35 +0000 Subject: [PATCH 04/12] docs: preserve frozen stop re-enable contract --- docs/specs/stop-reenable-contract-v1.md | 270 ++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 docs/specs/stop-reenable-contract-v1.md diff --git a/docs/specs/stop-reenable-contract-v1.md b/docs/specs/stop-reenable-contract-v1.md new file mode 100644 index 000000000..d9d3ffca0 --- /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. From 74f2ea5150608d9fbb51495f6a14095c6b226729 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:02:51 +0000 Subject: [PATCH 05/12] test: preserve frozen renewal-state vector --- .../tests/fixtures/renewal-state-v1.json | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tinycloud-node-server/tests/fixtures/renewal-state-v1.json 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 000000000..68f82122c --- /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 From 72265bf3a0a5096324aee2853f7cd49fcb43b8e3 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:51:16 +0000 Subject: [PATCH 06/12] fix: address renewal CI regressions Details: TC-2713 --- test/m1-realdata-e2e/Cargo.lock | 2 ++ tinycloud-core/src/auth_graph.rs | 2 +- tinycloud-core/src/db.rs | 7 +++++-- .../tests/historical_cacao_revocation.rs | 19 +++++++++++++++++++ .../tests/postgres_round_trip_budget.rs | 13 ++++++++----- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/test/m1-realdata-e2e/Cargo.lock b/test/m1-realdata-e2e/Cargo.lock index 27dc75b02..65a77b4dd 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/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index f84083380..2fa2c703a 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -1255,7 +1255,7 @@ mod tests { .sign_stamp( &original_cid, &lease_cid, - 0, + 1, &previous.to_cid(0x55).to_string(), &end_text, ) diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 46c9de864..9e5fe0ce6 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -712,7 +712,10 @@ where where C: ConnectionTrait, { - let roots = [target]; + 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.secrets.renewal_node_did(), self.secrets.renewal_seed()) { (Some(stable_node_did), Some(seed)) => { let (renewer_did, epoch, _) = @@ -746,7 +749,7 @@ where } })?, }; - Ok(snapshot.effective_expiry(&target)) + Ok(snapshot.effective_expiry(&effective_root)) } } diff --git a/tinycloud-core/tests/historical_cacao_revocation.rs b/tinycloud-core/tests/historical_cacao_revocation.rs index c4cbe7372..2d8ecb390 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/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index 3cbdb5d86..c7de7d63f 100644 --- a/tinycloud-node-server/tests/postgres_round_trip_budget.rs +++ b/tinycloud-node-server/tests/postgres_round_trip_budget.rs @@ -168,15 +168,18 @@ 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-aware `/delegate` +/// validation reads the persisted renewer identity once per request under a +/// share lock; full flows therefore add two round trips for their two +/// delegation requests. Fusion follow-up: TC-2426. const BUDGETS: [(&str, u64); 7] = [ - ("sign-in", 71), - ("kv put", 43), + ("sign-in", 73), + ("kv put", 45), ("kv get", 16), ("kv batch get", 16), ("kv batch head", 16), - ("kv put 2 blobs", 49), - ("kv put 1 blob 2 keys", 46), + ("kv put 2 blobs", 51), + ("kv put 1 blob 2 keys", 48), ]; /// Full sign-in plus put plus get rounds run before the measured one. From 0dc4c0589fe2b88172bfbb534694c4acf9b8b286 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:24:44 +0000 Subject: [PATCH 07/12] perf: cache renewal identity at startup --- tinycloud-core/src/db.rs | 173 +++++++++--------- tinycloud-core/src/renewal.rs | 146 +++++++++++---- tinycloud-node-server/src/lib.rs | 7 +- tinycloud-node-server/src/renewal.rs | 9 +- .../tests/postgres_round_trip_budget.rs | 15 +- 5 files changed, 206 insertions(+), 144 deletions(-) diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 9e5fe0ce6..a7d2453d7 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, @@ -614,11 +615,20 @@ where } delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; tx.commit().await?; - if let Some((seed, _)) = secrets.renewal_seed().zip(secrets.renewal_node_did()) { - crate::renewal::initialize_renewal_identity(&pool, seed) - .await - .map_err(|error| DbErr::Custom(error.to_string()))?; - } + 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()); @@ -627,6 +637,7 @@ where conn, storage, secrets, + renewal_identity, encryption, sql_sizes: SqlSizes::default(), delegated_sql_caveats: false, @@ -716,28 +727,24 @@ where .await? .map_or(target, |delegation| delegation.id); let roots = [effective_root]; - let snapshot = match (self.secrets.renewal_node_did(), self.secrets.renewal_seed()) { - (Some(stable_node_did), Some(seed)) => { - let (renewer_did, epoch, _) = - crate::renewal::current_renewal_identity_from_seed(&self.conn, seed).await?; - crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( - &self.conn, - &roots, - &stable_node_did, - &renewer_did, - 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 - } - })? - } + 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 { @@ -774,10 +781,19 @@ impl SpaceDatabase { where C: ConnectionTrait + TransactionTrait, { - crate::renewal::issue_stamp(&self.conn, request, &self.secrets).await + 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 { @@ -1963,6 +1979,7 @@ where &tx, &self.storage, &self.secrets, + self.renewal_identity.as_ref(), events, self.encryption.as_ref(), None, @@ -2286,6 +2303,7 @@ where tx, &self.storage, &self.secrets, + self.renewal_identity.as_ref(), delegations .into_iter() .map(|delegation| Event::Delegation(Box::new(delegation))) @@ -2909,32 +2927,20 @@ 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_result = - match (self.secrets.renewal_node_did(), self.secrets.renewal_seed()) { - (Some(stable_node_did), Some(seed)) => { - async { - let (renewer_did, epoch, _) = - crate::renewal::current_renewal_identity_from_seed(&tx, seed) - .await - .map_err(|error| { - revocation::ChainTraversalError::Db(DbErr::Custom( - error.to_string(), - )) - })?; - crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( - &tx, - &roots, - &stable_node_did, - &renewer_did, - epoch, - now.unix_timestamp(), - ) - .await - } - .await - } - _ => 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) => { @@ -3012,6 +3018,7 @@ where &tx, &self.storage, &self.secrets, + self.renewal_identity.as_ref(), vec![event], self.encryption.as_ref(), Some(&auth_graph), @@ -3316,34 +3323,20 @@ where .as_ref() .and_then(|sync| sync.retention_grant), ); - let graph_result = - match (self.secrets.renewal_node_did(), self.secrets.renewal_seed()) { - (Some(stable_node_did), Some(seed)) => { - async { - let (renewer_did, epoch, _) = - crate::renewal::current_renewal_identity_from_seed( - &self.conn, seed, - ) - .await - .map_err(|error| { - revocation::ChainTraversalError::Db(DbErr::Custom( - error.to_string(), - )) - })?; - crate::auth_graph::AuthGraphSnapshot::load_with_renewal_identity( - &self.conn, - &roots, - &stable_node_did, - &renewer_did, - epoch, - now.unix_timestamp(), - ) - .await - } - .await - } - _ => crate::auth_graph::AuthGraphSnapshot::load(&self.conn, &roots).await, - }; + 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 + } + 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 => { @@ -4086,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>, @@ -4109,14 +4103,13 @@ pub(crate) async fn transact( 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 = match (secrets.renewal_node_did(), renewal_seed) { - (Some(stable_node_did), Some(seed)) => { - let (renewer_did, epoch, _) = - crate::renewal::current_renewal_identity_from_seed(db, seed).await?; - Some((stable_node_did, renewer_did, epoch)) - } - _ => None, - }; + 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 { diff --git a/tinycloud-core/src/renewal.rs b/tinycloud-core/src/renewal.rs index 4cc3f6f5a..3d28b6334 100644 --- a/tinycloud-core/src/renewal.rs +++ b/tinycloud-core/src/renewal.rs @@ -64,6 +64,16 @@ pub struct StampIssue { 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, @@ -422,28 +432,41 @@ pub async fn current_renewal_identity_from_seed( 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()).await + 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 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 = (SELECT renewer_did FROM renewal_identity WHERE id = 1) AND renewer_epoch = (SELECT epoch FROM renewal_identity WHERE id = 1) ORDER BY lease_cid", - [ - Value::Bytes(Some(Box::new(Vec::::from(original)))), - Value::TimeDateTime(Some(Box::new(now))), - ], - )).await?; + 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")?; @@ -469,17 +492,24 @@ async fn lease_for_original_at( } Ok(selected) } -/// Lists the active leases belonging to the node's persisted current renewer epoch. +/// 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 now = db_datetime(OffsetDateTime::now_utc().unix_timestamp())?; + 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 = (SELECT renewer_did FROM renewal_identity WHERE id = 1) AND renewer_epoch = (SELECT epoch FROM renewal_identity WHERE id = 1) ORDER BY original_canonical", - [Value::TimeDateTime(Some(Box::new(now)))], + "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()); @@ -487,7 +517,9 @@ pub async fn active_leases_for_current_epoch( 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(db, &original_cid).await? { + if let Some(lease) = + lease_for_original_at(db, &original_cid, verifier_now, identity).await? + { leases.push(lease); } } @@ -498,11 +530,13 @@ 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 @@ -512,6 +546,7 @@ 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}; @@ -525,7 +560,7 @@ async fn authorize_renewal_invocation_at( { return Err(RenewalStoreError::Unauthorized); } - let lease = lease_for_original_at(db, original_cid, verifier_now) + let lease = lease_for_original_at(db, original_cid, verifier_now, identity) .await? .ok_or(RenewalStoreError::LeaseUnavailable)?; if invocation.invoker != lease.renewer_did { @@ -953,11 +988,13 @@ 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 @@ -967,6 +1004,7 @@ async fn issue_stamp_at( conn: &C, request: StampIssue, secret: &StaticSecret, + identity: &RenewalNodeIdentity, now: i64, ) -> Result { let (_, original) = canonical_cid(&request.original_cid)?; @@ -992,9 +1030,15 @@ async fn issue_stamp_at( } return Err(error.into()); } - authorize_renewal_invocation_at(&tx, &request.invocation, &request.original_cid, now) - .await?; - issue_stamp_in_transaction(&tx, request, secret, now).await + 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 { @@ -1013,6 +1057,7 @@ 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)?; @@ -1037,18 +1082,20 @@ async fn issue_stamp_in_transaction( 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_did, current_epoch, _) = current_renewal_identity(tx, secret).await?; + 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 != current_did || renewer_epoch != current_epoch as i64 { + if renewer_did != identity.renewer_did || renewer_epoch != current_epoch_i64 { return Err(RenewalStoreError::IdentityEpoch); } let snapshot = load_renewal_snapshot( tx, &[original, lease], - &secret.node_did(), - ¤t_did, + &identity.stable_node_did, + &identity.renewer_did, current_epoch, now, ) @@ -1080,8 +1127,8 @@ async fn issue_stamp_in_transaction( } let signer = RenewalSigner::from_seed( secret.derive_key(RENEWER_KEY_CONTEXT), - secret.node_did(), - u64::try_from(renewer_epoch).map_err(|_| RenewalStoreError::InvalidInput)?, + identity.stable_node_did.clone(), + current_epoch, ); let new_end_string = unix_time(new_end)? .format(&time::format_description::well_known::Rfc3339) @@ -1204,17 +1251,16 @@ pub async fn effective_end( original_cid: &str, lease_cid: &str, verifier_now: i64, - secret: &StaticSecret, + identity: &RenewalNodeIdentity, ) -> Result, RenewalStoreError> { let (_, original) = canonical_cid(original_cid)?; let (_, lease) = canonical_cid(lease_cid)?; - let (renewer_did, renewer_epoch, _) = current_renewal_identity(db, secret).await?; let snapshot = load_renewal_snapshot( db, &[original, lease], - &secret.node_did(), - &renewer_did, - renewer_epoch, + &identity.stable_node_did, + &identity.renewer_did, + identity.epoch, verifier_now, ) .await?; @@ -1620,6 +1666,11 @@ mod tests { 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; @@ -1787,30 +1838,30 @@ mod tests { 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, now).await, + 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, now).await, + authorize_renewal_invocation_at(&db, &extra_parent, original_cid, &identity, now).await, Err(RenewalStoreError::Unauthorized) )); - authorize_renewal_invocation_at(&db, &request.invocation, original_cid, now) + authorize_renewal_invocation_at(&db, &request.invocation, original_cid, &identity, now) .await .unwrap(); - let issued = issue_stamp_at(&db, request.clone(), &secret, now) + 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, now).await, + issue_stamp_at(&db, request.clone(), &secret, &identity, now).await, Err(RenewalStoreError::Replay) )); assert_eq!( - effective_end(&db, original_cid, lease_cid, now, &secret) + effective_end(&db, original_cid, lease_cid, now, &identity) .await .unwrap(), Some(issued.new_end) @@ -1818,6 +1869,11 @@ mod tests { 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 next_snapshot = load_renewal_snapshot( &db, &[original, lease], @@ -1832,11 +1888,21 @@ mod tests { next_snapshot.effective_expiry(&original), Some(original_expiry) ); - assert!(lease_for_original_at(&db, original_cid, now) + 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 - .unwrap() - .is_none()); - assert!(issue_stamp_at(&db, request, &secret, now).await.is_err()); + .is_err()); let next_not_before = now - 100; let next_expiry = next_not_before + MAX_LEASE_SECONDS; @@ -1899,7 +1965,7 @@ mod tests { 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) + lease_for_original_at(&db, original_cid, now, &next_identity) .await .unwrap() .unwrap() diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 7292f99c1..faa3e4985 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -863,9 +863,10 @@ 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_core::renewal::current_renewal_identity(tinycloud.connection(), &key_setup) - .await?; + 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, diff --git a/tinycloud-node-server/src/renewal.rs b/tinycloud-node-server/src/renewal.rs index 4b954f984..5b107e581 100644 --- a/tinycloud-node-server/src/renewal.rs +++ b/tinycloud-node-server/src/renewal.rs @@ -82,14 +82,17 @@ pub(crate) async fn scheduler_tick( ) -> Result<()> { let now = time::OffsetDateTime::now_utc().unix_timestamp(); let conn = tinycloud.connection(); - let leases = renewal::active_leases_for_current_epoch(conn).await?; + 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 lease in leases { - if lease.state != "active" || lease.renewer_did != secret.renewal_did() { + if lease.state != "active" || lease.renewer_did != identity.renewer_did { continue; } let Some(end) = - renewal::effective_end(conn, &lease.original_cid, &lease.lease_cid, now, secret) + renewal::effective_end(conn, &lease.original_cid, &lease.lease_cid, now, identity) .await? else { continue; diff --git a/tinycloud-node-server/tests/postgres_round_trip_budget.rs b/tinycloud-node-server/tests/postgres_round_trip_budget.rs index c7de7d63f..a8572f3f7 100644 --- a/tinycloud-node-server/tests/postgres_round_trip_budget.rs +++ b/tinycloud-node-server/tests/postgres_round_trip_budget.rs @@ -168,18 +168,17 @@ 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. Renewal-aware `/delegate` -/// validation reads the persisted renewer identity once per request under a -/// share lock; full flows therefore add two round trips for their two -/// delegation requests. 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", 73), - ("kv put", 45), + ("sign-in", 71), + ("kv put", 43), ("kv get", 16), ("kv batch get", 16), ("kv batch head", 16), - ("kv put 2 blobs", 51), - ("kv put 1 blob 2 keys", 48), + ("kv put 2 blobs", 49), + ("kv put 1 blob 2 keys", 46), ]; /// Full sign-in plus put plus get rounds run before the measured one. From 69c5e8966069b48f4760cb451f6ab473f1c128ad Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:36:57 +0000 Subject: [PATCH 08/12] fix: reject renewal issuance after identity epoch change Details: TC-2788 --- tinycloud-core/src/renewal.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tinycloud-core/src/renewal.rs b/tinycloud-core/src/renewal.rs index 3d28b6334..3e55fbae2 100644 --- a/tinycloud-core/src/renewal.rs +++ b/tinycloud-core/src/renewal.rs @@ -1062,6 +1062,11 @@ async fn issue_stamp_in_transaction( ) -> 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, @@ -1874,6 +1879,13 @@ mod tests { 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], From d3d4cf476b2230eba1419eb4aac98797de6e1cb3 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:42:49 +0000 Subject: [PATCH 09/12] docs: clarify delegation renewal release note --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 973bcad9e..c0e33edc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] ### Changes -- Delegation renewal uses owner-signed, exact-CID leases and signed bounded renewal stamps. Lease revocation immediately ends renewed access; stop/re-enable controls are not included, so revocation is the only way to turn renewal off and cannot be undone. +- 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. From 4697a330225bfcebcb8c0b267ee2f8f6193dac1f Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:17:39 +0000 Subject: [PATCH 10/12] fix: use canonical renewal DID derivation --- tinycloud-node-server/src/renewal.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tinycloud-node-server/src/renewal.rs b/tinycloud-node-server/src/renewal.rs index 5b107e581..785814976 100644 --- a/tinycloud-node-server/src/renewal.rs +++ b/tinycloud-node-server/src/renewal.rs @@ -6,7 +6,6 @@ pub use tinycloud_core::renewal::{ use anyhow::{anyhow, Result}; use tinycloud_auth::{ authorization::{make_invocation_from_uris, InvocationOptions, TinyCloudInvocation}, - resolver::DID_METHODS, ssi::jwk::{Algorithm, Base64urlUInt, OctetParams, Params, JWK}, }; use tinycloud_core::{ @@ -37,10 +36,7 @@ fn renewal_invocation_signer(secret: &StaticSecret) -> Result<(JWK, String)> { )), })); jwk.algorithm = Some(Algorithm::EdDSA); - let did = DID_METHODS.generate(&jwk, "key")?.to_string(); - if did != secret.renewal_did() { - return Err(anyhow!("derived renewal DID mismatch")); - } + let did = secret.renewal_did(); let fragment = did .rsplit_once(':') .map(|(_, fragment)| fragment) From 5ef53ac60dd096e8a81719bb568a64ce65806b43 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:09:35 +0000 Subject: [PATCH 11/12] fix: keep renewal scheduler progressing --- tinycloud-core/src/auth_graph.rs | 41 +++++++-- tinycloud-node-server/src/renewal.rs | 132 +++++++++++++++++++-------- 2 files changed, 127 insertions(+), 46 deletions(-) diff --git a/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index 2fa2c703a..be1f648a9 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -1368,9 +1368,26 @@ mod tests { 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, @@ -1385,6 +1402,23 @@ mod tests { .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], @@ -1395,14 +1429,9 @@ mod tests { ) .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(), - }; crate::models::invocation::authorize_holder_with_graph( &db, - "did:key:actor", + holder, &[capability], &[original.to_cid(0x55)], OffsetDateTime::from_unix_timestamp(2_150).unwrap(), diff --git a/tinycloud-node-server/src/renewal.rs b/tinycloud-node-server/src/renewal.rs index 785814976..5ad0950a2 100644 --- a/tinycloud-node-server/src/renewal.rs +++ b/tinycloud-node-server/src/renewal.rs @@ -11,9 +11,10 @@ use tinycloud_auth::{ use tinycloud_core::{ events::Invocation, keys::StaticSecret, - renewal::{self, LeaseRecord, RenewalStoreError, StampIssue}, + renewal::{self, LeaseRecord, 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; @@ -83,48 +84,62 @@ pub(crate) async fn scheduler_tick( .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 lease in leases { - if lease.state != "active" || lease.renewer_did != identity.renewer_did { - continue; - } - let Some(end) = - renewal::effective_end(conn, &lease.original_cid, &lease.lease_cid, now, identity) - .await? - else { - continue; - }; - if !due_for_renewal(now, end, lease.period_seconds) { - continue; - } - 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, - }; - if let Err(error) = tinycloud.issue_renewal_stamp(issue).await { - if !matches!( - &error, - RenewalStoreError::LeaseUnavailable | RenewalStoreError::IdentityEpoch - ) { - ::tracing::warn!( - event = "renewal_scheduler_issue_failed", - error = %error, - "internal renewal scheduler did not issue a stamp" - ); + 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 { + if let Err(error) = process(lease).await { + ::tracing::warn!( + event = "renewal_scheduler_lease_failed", + 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}; @@ -133,12 +148,13 @@ mod scheduler_tests { #[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: secret.renewal_did(), + renewer_did: expected_renewer_did.clone(), renewer_epoch: 7, lease_expiry: 2_000, period_seconds: 30, @@ -148,7 +164,12 @@ mod scheduler_tests { let (signed, expiry) = build_scheduled_invocation(&lease, &signer, &verification_method, 1_000)?; let info = InvocationInfo::try_from(signed)?; - assert_eq!(info.invoker, lease.renewer_did); + 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); @@ -173,6 +194,37 @@ mod scheduler_tests { 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)] From c4f9cbba369bc0de1fc85473752865412fa51775 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:18:21 +0000 Subject: [PATCH 12/12] fix: preserve renewal scheduler diagnostics --- tinycloud-node-server/src/renewal.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tinycloud-node-server/src/renewal.rs b/tinycloud-node-server/src/renewal.rs index 5ad0950a2..2dbdef899 100644 --- a/tinycloud-node-server/src/renewal.rs +++ b/tinycloud-node-server/src/renewal.rs @@ -11,7 +11,7 @@ use tinycloud_auth::{ use tinycloud_core::{ events::Invocation, keys::StaticSecret, - renewal::{self, LeaseRecord, StampIssue}, + renewal::{self, LeaseRecord, RenewalStoreError, StampIssue}, }; use std::future::Future; @@ -130,9 +130,22 @@ where 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" );