diff --git a/docs/specs/renewal-stamp-v1.md b/docs/specs/renewal-stamp-v1.md new file mode 100644 index 000000000..9681ad437 --- /dev/null +++ b/docs/specs/renewal-stamp-v1.md @@ -0,0 +1,55 @@ +# 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. + +## 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: + +- 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. + +## Signed stamp bytes + +The signed byte sequence is the deterministic DAG-CBOR encoding of exactly this two-entry map: + +```text +{ + "domain": "tinycloud/node/renewal-stamp/v1", + "unsigned": { + "type": "TinyCloudRenewalStamp", + "version": 1, + "originalCid": , + "leaseCid": , + "leaseGeneration": , + "previousStampCid": , + "nodeDid": , + "renewerEpoch": , + "newEnd": + } +} +``` + +`previousStampCid` is non-null. The first stamp sets it to that lease's CID. `leaseGeneration` records the issuance generation; it is not a use-time equality check. `renewerEpoch` is the current renewer identity epoch. `newEnd` is serialized as a UTC RFC3339 string. The domain value has no NUL terminator; there is no additional domain prefix outside the encoded map. + +Encode with the repository's deterministic `serde_ipld_dagcbor` representation. The detached Ed25519 signature is over the exact encoded bytes above; it is not embedded in those bytes. The stamp CID is CIDv1 with raw codec `0x55` and Blake3-256 over those exact bytes; the detached signature is excluded from the CID input. + +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 + +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. + +- Original placeholder CID: `bafkr4ifaiwrq52xkndrm7jbwqvywq3tkilydyxrj24oofckit6ink7f6hy` +- Lease placeholder CID and first `previousStampCid`: `bafkr4iaiwsgu3x5bvuigtms3hrlhrwpshbv5lxar5oj7uhh5dfg2sbcl4q` +- Stamp CID: `bafkr4ibtywvyzwld56wljgytygpis2dddualt6h6b343e52rzmsegyw5nq` +- Signer public key (hex): `49fd44f3fe8930b0e89f61c51333195578deacaf22105a78f647d8c05afc7c5d` +- Detached signature (hex): `34caf6cc60615a4c0d4f4e934c0263dde1c17fdd5becfa0f693337987b0250f0b430d7fe4518db41a882cc26ee979603736b883798fb83bbc95fb61af077020d` + +Exact canonical DAG-CBOR bytes (hex): + +```text +a266646f6d61696e781f74696e79636c6f75642f6e6f64652f72656e6577616c2d7374616d702f763168756e7369676e6564a964747970657554696e79436c6f756452656e6577616c5374616d70666e6577456e6474323033302d30312d30325430333a30343a30355a676e6f646544696478386469643a6b65793a7a364d6b7058766555796a7043446536503833724e5971415a6d6f546339716f704a364742755251526f747a337a46376776657273696f6e01686c65617365436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c34716b6f726967696e616c436964783b6261666b7234696661697772713532786b6e64726d376a6277717679777133746b696c79647978726a32346f6f66636b697436696e6b37663668796c72656e6577657245706f6368076f6c6561736547656e65726174696f6e037070726576696f75735374616d70436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c3471 +``` diff --git a/tinycloud-node-server/tests/fixtures/renewal-stamp-v1.json b/tinycloud-node-server/tests/fixtures/renewal-stamp-v1.json new file mode 100644 index 000000000..98519aac3 --- /dev/null +++ b/tinycloud-node-server/tests/fixtures/renewal-stamp-v1.json @@ -0,0 +1,34 @@ +{ + "schema": "tinycloud/node/renewal-stamp-vector/v1", + "renewAbility": "tinycloud.delegation/renew", + "renewResourcePrefix": "urn:cid:", + "renewerKeyContext": "tinycloud/node/renewer/v1", + "stampDomain": "tinycloud/node/renewal-stamp/v1", + "testOnlyMasterSecretHex": "4242424242424242424242424242424242424242424242424242424242424242", + "originalArtifact": { + "type": "RenewalVectorOriginalDelegation", + "version": 1 + }, + "leaseArtifact": { + "type": "RenewalVectorLease", + "version": 1 + }, + "stamp": { + "domain": "tinycloud/node/renewal-stamp/v1", + "unsigned": { + "type": "TinyCloudRenewalStamp", + "version": 1, + "originalCid": "bafkr4ifaiwrq52xkndrm7jbwqvywq3tkilydyxrj24oofckit6ink7f6hy", + "leaseCid": "bafkr4iaiwsgu3x5bvuigtms3hrlhrwpshbv5lxar5oj7uhh5dfg2sbcl4q", + "leaseGeneration": 3, + "previousStampCid": "bafkr4iaiwsgu3x5bvuigtms3hrlhrwpshbv5lxar5oj7uhh5dfg2sbcl4q", + "nodeDid": "did:key:z6MkpXveUyjpCDe6P83rNYqAZmoTc9qopJ6GBuRQRotz3zF7", + "renewerEpoch": 7, + "newEnd": "2030-01-02T03:04:05Z" + } + }, + "canonicalDagCborHex": "a266646f6d61696e781f74696e79636c6f75642f6e6f64652f72656e6577616c2d7374616d702f763168756e7369676e6564a964747970657554696e79436c6f756452656e6577616c5374616d70666e6577456e6474323033302d30312d30325430333a30343a30355a676e6f646544696478386469643a6b65793a7a364d6b7058766555796a7043446536503833724e5971415a6d6f546339716f704a364742755251526f747a337a46376776657273696f6e01686c65617365436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c34716b6f726967696e616c436964783b6261666b7234696661697772713532786b6e64726d376a6277717679777133746b696c79647978726a32346f6f66636b697436696e6b37663668796c72656e6577657245706f6368076f6c6561736547656e65726174696f6e037070726576696f75735374616d70436964783b6261666b7234696169777367753378356276756967746d733368726c6872777073686276356c786172356f6a3775686835646667327362636c3471", + "stampCid": "bafkr4ibtywvyzwld56wljgytygpis2dddualt6h6b343e52rzmsegyw5nq", + "signerPublicKeyHex": "49fd44f3fe8930b0e89f61c51333195578deacaf22105a78f647d8c05afc7c5d", + "signatureHex": "34caf6cc60615a4c0d4f4e934c0263dde1c17fdd5becfa0f693337987b0250f0b430d7fe4518db41a882cc26ee979603736b883798fb83bbc95fb61af077020d" +} diff --git a/tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs b/tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs new file mode 100644 index 000000000..b24d03a4c --- /dev/null +++ b/tinycloud-node-server/tests/renewal_stamp_v1_vectors.rs @@ -0,0 +1,89 @@ +//! Contract-only known-answer vector for the v1 renewal stamp. +//! +//! The fixture pins canonical DAG-CBOR bytes, the raw-Blake3 CID, and the +//! detached Ed25519 signature before runtime implementation. Its fixed master +//! seed is synthetic and public; it is not a production key. + +use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; +use serde_json::{json, Value}; +use tinycloud_core::{hash::hash, keys::StaticSecret}; + +fn raw_blake3_cid(bytes: &[u8]) -> String { + hash(bytes).to_cid(0x55).to_string() +} + +fn dag_cbor(value: &Value) -> Vec { + serde_ipld_dagcbor::to_vec(value).expect("test vector DAG-CBOR serializes") +} + +fn decode_hex(value: &Value) -> Vec { + hex::decode(value.as_str().expect("hex string")).expect("valid fixture hex") +} + +#[test] +fn renewal_stamp_v1_matches_pinned_bytes_cid_and_signature() { + let fixture: Value = serde_json::from_str(include_str!("fixtures/renewal-stamp-v1.json")) + .expect("renewal vector JSON parses"); + let key_context = fixture["renewerKeyContext"].as_str().unwrap().as_bytes(); + 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 signer = SigningKey::from_bytes(&signing_seed); + + let original_cid = raw_blake3_cid(&dag_cbor(&fixture["originalArtifact"])); + let lease_cid = raw_blake3_cid(&dag_cbor(&fixture["leaseArtifact"])); + let resource = format!( + "{}{}", + fixture["renewResourcePrefix"].as_str().unwrap(), + original_cid + ); + + assert_eq!(fixture["renewAbility"], "tinycloud.delegation/renew"); + assert_eq!( + fixture["renewResourcePrefix"], "urn:cid:", + "the control capability targets the canonical original CID" + ); + assert_eq!(resource, format!("urn:cid:{original_cid}")); + + let stamp = &fixture["stamp"]; + assert_eq!(stamp["domain"], domain); + assert_eq!(stamp["unsigned"]["type"], "TinyCloudRenewalStamp"); + assert_eq!(stamp["unsigned"]["version"], 1); + assert_eq!(stamp["unsigned"]["originalCid"], original_cid); + assert_eq!(stamp["unsigned"]["leaseCid"], lease_cid); + assert_eq!(stamp["unsigned"]["previousStampCid"], lease_cid); + assert_eq!(stamp["unsigned"]["nodeDid"], node_secret.node_did()); + assert_eq!(stamp["unsigned"]["leaseGeneration"], 3); + assert_eq!(stamp["unsigned"]["renewerEpoch"], 7); + assert_eq!(stamp["unsigned"]["newEnd"], "2030-01-02T03:04:05Z"); + + let bytes = dag_cbor(stamp); + assert_eq!(hex::encode(&bytes), fixture["canonicalDagCborHex"]); + assert_eq!(raw_blake3_cid(&bytes), fixture["stampCid"]); + assert_eq!( + hex::encode(signer.verifying_key().to_bytes()), + fixture["signerPublicKeyHex"] + ); + + let signature_bytes = signer.sign(&bytes).to_bytes(); + assert_eq!(hex::encode(signature_bytes), fixture["signatureHex"]); + let signature = ed25519_dalek::Signature::from_slice(&decode_hex(&fixture["signatureHex"])) + .expect("64-byte Ed25519 signature"); + let verifying_key = VerifyingKey::from_bytes(&signer.verifying_key().to_bytes()) + .expect("derived public key is valid"); + verifying_key + .verify(&bytes, &signature) + .expect("pinned detached signature verifies over exact DAG-CBOR bytes"); + + let decoded: Value = + serde_ipld_dagcbor::from_slice(&bytes).expect("canonical DAG-CBOR decodes"); + assert_eq!( + dag_cbor(&decoded), + bytes, + "DAG-CBOR re-encoding is byte-identical" + ); +}