From 0fef8086718914987e6b4b5b5e5b4c1ebd36dfd1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 11:22:24 -0400 Subject: [PATCH] wip(trust): park agent's signed-manifest module + release-root key off main Moved here so main stays clean for the v1.7.98 release. Contains the trust/ module (canonical.rs, did.rs, signed_doc.rs) + seed::derive_release_root_ed25519. Not wired into the build yet. Continue this work on this branch. --- core/archipelago/src/seed.rs | 54 +++++++ core/archipelago/src/trust/anchor.rs | 71 +++++++++ core/archipelago/src/trust/canonical.rs | 87 +++++++++++ core/archipelago/src/trust/did.rs | 56 +++++++ core/archipelago/src/trust/mod.rs | 23 +++ core/archipelago/src/trust/signed_doc.rs | 191 +++++++++++++++++++++++ 6 files changed, 482 insertions(+) create mode 100644 core/archipelago/src/trust/anchor.rs create mode 100644 core/archipelago/src/trust/canonical.rs create mode 100644 core/archipelago/src/trust/did.rs create mode 100644 core/archipelago/src/trust/mod.rs create mode 100644 core/archipelago/src/trust/signed_doc.rs diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index ffe9532a..7b3def15 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -8,6 +8,8 @@ //! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key //! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key //! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key +//! ├── HKDF(seed, "archipelago/release/root/ed25519/v1") → Release-root signing key +//! │ (publisher-only; nodes pin the PUBLIC key — see trust::anchor) //! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519 //! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06) //! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet @@ -34,6 +36,7 @@ const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1"; const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1"; const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1"; const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1"; +const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1"; // ─── MasterSeed ───────────────────────────────────────────────────────── @@ -88,6 +91,21 @@ pub fn derive_node_ed25519(seed: &MasterSeed) -> Result { Ok(SigningKey::from_bytes(&derived)) } +/// Derive the fleet **release-root** Ed25519 signing key. +/// +/// This is a *publisher-side* derivation: only the holder of the release master +/// seed runs it (e.g. in the signing ceremony). Fleet nodes never derive this — +/// they pin the corresponding PUBLIC key as a trust anchor (see +/// `crate::trust::anchor`) and use it to verify signed manifests/catalogs. +/// +/// Keeping it seed-derived means the signing key is reproducible from a +/// backed-up mnemonic (disaster recovery) rather than a loose key file, and it +/// is domain-separated from every node/identity key by its HKDF info string. +pub fn derive_release_root_ed25519(seed: &MasterSeed) -> Result { + let derived = hkdf_derive_32(seed.as_bytes(), RELEASE_ROOT_ED25519_INFO)?; + Ok(SigningKey::from_bytes(&derived)) +} + /// Derive an identity's Ed25519 signing key by index. pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result { let info = format!("archipelago/identity/{}/ed25519/v1", index); @@ -561,4 +579,40 @@ mod tests { ); } + #[test] + fn test_release_root_deterministic_and_domain_separated() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let a = derive_release_root_ed25519(&seed).unwrap(); + let b = derive_release_root_ed25519(&seed).unwrap(); + assert_eq!( + a.verifying_key().as_bytes(), + b.verifying_key().as_bytes(), + "Same mnemonic must produce the same release-root key" + ); + // Must NOT collide with the node key — different HKDF domain. + let node = derive_node_ed25519(&seed).unwrap(); + assert_ne!( + a.verifying_key().as_bytes(), + node.verifying_key().as_bytes(), + "Release-root key must be domain-separated from the node key" + ); + } + + #[test] + fn test_release_root_known_answer() { + // KAT pins the derivation so the signing ceremony, the pinned anchor, + // and any external verifier agree on the bytes for a given mnemonic. + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let key = derive_release_root_ed25519(&seed).unwrap(); + assert_eq!( + hex::encode(key.to_bytes()), + "__RELEASE_ROOT_PRIV_HEX__", + "release-root private key KAT" + ); + assert_eq!( + hex::encode(key.verifying_key().to_bytes()), + "__RELEASE_ROOT_PUB_HEX__", + "release-root public key KAT" + ); + } } diff --git a/core/archipelago/src/trust/anchor.rs b/core/archipelago/src/trust/anchor.rs new file mode 100644 index 00000000..933520c0 --- /dev/null +++ b/core/archipelago/src/trust/anchor.rs @@ -0,0 +1,71 @@ +//! The fleet's pinned **release-root** trust anchor. +//! +//! Every node ships the release-root *public* key. Signed manifests and the app +//! catalog must be signed by the corresponding private key (derived once, in +//! the signing ceremony, via `seed::derive_release_root_ed25519`). Pinning the +//! key in the binary is what makes a swapped-in mirror key detectable. +//! +//! Until the ceremony runs against the real release master seed, the pinned +//! constant is `None`. While `None`, signature verification still runs and +//! still rejects tampered documents, but it cannot enforce signer *identity* +//! (see `signed_doc::SignatureStatus::anchored`). Set +//! `ARCHY_RELEASE_ROOT_PUBKEY` (64-char hex) to pin a key at runtime for +//! staging/test fleets before the constant is baked in. + +use ed25519_dalek::VerifyingKey; + +/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars). +/// +/// TODO(dht Phase 0): bake the real value here after the signing ceremony. +/// Generate it with: `scripts/release-root-ceremony.sh pubkey`. +pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None; + +const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY"; + +/// Resolve the pinned release-root public key, if any. +/// +/// Runtime env override wins over the baked-in constant so a test fleet can pin +/// a ceremony key without a rebuild. Malformed values are ignored (treated as +/// "not pinned") rather than crashing the node. +pub fn release_root_pubkey() -> Option { + if let Ok(hex_str) = std::env::var(ENV_OVERRIDE) { + if let Some(key) = parse_pubkey_hex(hex_str.trim()) { + return Some(key); + } + tracing::warn!( + "{} is set but not a valid 32-byte hex Ed25519 key; ignoring", + ENV_OVERRIDE + ); + } + RELEASE_ROOT_PUBKEY_HEX.and_then(parse_pubkey_hex) +} + +fn parse_pubkey_hex(s: &str) -> Option { + let bytes = hex::decode(s).ok()?; + let arr: [u8; 32] = bytes.as_slice().try_into().ok()?; + VerifyingKey::from_bytes(&arr).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unset_constant_is_none() { + // Default build ships no pinned anchor yet. + assert!(RELEASE_ROOT_PUBKEY_HEX.is_none()); + } + + #[test] + fn parses_valid_hex() { + let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]).verifying_key(); + let parsed = parse_pubkey_hex(&hex::encode(key.to_bytes())).unwrap(); + assert_eq!(parsed.as_bytes(), key.as_bytes()); + } + + #[test] + fn rejects_malformed_hex() { + assert!(parse_pubkey_hex("nothex").is_none()); + assert!(parse_pubkey_hex("abcd").is_none()); + } +} diff --git a/core/archipelago/src/trust/canonical.rs b/core/archipelago/src/trust/canonical.rs new file mode 100644 index 00000000..80f6c0ef --- /dev/null +++ b/core/archipelago/src/trust/canonical.rs @@ -0,0 +1,87 @@ +//! Canonical JSON for signing — a pragmatic subset of RFC 8785 (JCS). +//! +//! Signatures are computed over a *byte-exact* serialization so that a verifier +//! reproduces the same preimage the signer hashed. We guarantee: +//! +//! * object keys recursively sorted (lexicographic by Rust `str` ordering, +//! i.e. Unicode scalar value — matches JCS for the ASCII keys we use), +//! * no insignificant whitespace, +//! * arrays preserved in order. +//! +//! We do NOT implement JCS number canonicalization (ECMAScript shortest-form). +//! Archipelago manifests/catalogs carry only integers, strings, bools, arrays +//! and objects, for which `serde_json`'s output is already unambiguous. If a +//! float ever enters a signed document this must be hardened (or rejected). +//! `contains_float()` lets callers enforce that invariant. + +use serde_json::Value; + +/// Serialize `value` to canonical JSON bytes (sorted keys, compact). +/// +/// Rebuilds every object through a `BTreeMap` so the result is independent of +/// the `serde_json/preserve_order` feature being toggled on anywhere in the +/// dependency graph. +pub fn to_canonical_bytes(value: &Value) -> Vec { + let canonical = canonicalize(value); + // serde_json never fails to serialize a Value it produced. + serde_json::to_vec(&canonical).expect("canonical JSON serialization") +} + +/// Reject documents that contain a float anywhere — they are not safely +/// canonicalizable under this implementation. +pub fn contains_float(value: &Value) -> bool { + match value { + Value::Number(n) => n.as_i64().is_none() && n.as_u64().is_none(), + Value::Array(items) => items.iter().any(contains_float), + Value::Object(map) => map.values().any(contains_float), + _ => false, + } +} + +fn canonicalize(value: &Value) -> Value { + match value { + Value::Object(map) => { + // BTreeMap gives deterministic key ordering on serialize. + let sorted: std::collections::BTreeMap = map + .iter() + .map(|(k, v)| (k.clone(), canonicalize(v))) + .collect(); + serde_json::to_value(sorted).expect("canonical object") + } + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn key_order_does_not_change_bytes() { + let a = json!({"b": 1, "a": 2, "c": {"z": 1, "y": 2}}); + let b = json!({"c": {"y": 2, "z": 1}, "a": 2, "b": 1}); + assert_eq!(to_canonical_bytes(&a), to_canonical_bytes(&b)); + } + + #[test] + fn output_is_sorted_and_compact() { + let v = json!({"b": 1, "a": [3, 2, 1]}); + assert_eq!(to_canonical_bytes(&v), br#"{"a":[3,2,1],"b":1}"#.to_vec()); + } + + #[test] + fn array_order_is_preserved() { + let a = json!([1, 2, 3]); + let b = json!([3, 2, 1]); + assert_ne!(to_canonical_bytes(&a), to_canonical_bytes(&b)); + } + + #[test] + fn detects_floats() { + assert!(contains_float(&json!({"x": 1.5}))); + assert!(contains_float(&json!([1, 2, 0.1]))); + assert!(!contains_float(&json!({"x": 12345, "y": "s", "z": [1, 2]}))); + } +} diff --git a/core/archipelago/src/trust/did.rs b/core/archipelago/src/trust/did.rs new file mode 100644 index 00000000..a7e174a1 --- /dev/null +++ b/core/archipelago/src/trust/did.rs @@ -0,0 +1,56 @@ +//! `did:key` <-> Ed25519 public key, mirroring the encoding already used by +//! `identity_manager` so release-root DIDs are interchangeable with node DIDs. +//! +//! Format: `did:key:z` +//! (`0xed01` is the multicodec varint prefix for an Ed25519 public key.) + +use anyhow::{anyhow, Context, Result}; +use ed25519_dalek::VerifyingKey; + +const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01]; + +/// Encode an Ed25519 public key as a `did:key` string. +pub fn did_key_for_ed25519(key: &VerifyingKey) -> String { + let mut bytes = Vec::with_capacity(34); + bytes.extend_from_slice(&ED25519_MULTICODEC); + bytes.extend_from_slice(key.as_bytes()); + format!("did:key:z{}", bs58::encode(bytes).into_string()) +} + +/// Decode a `did:key` string into an Ed25519 verifying key. +pub fn ed25519_pubkey_from_did_key(did: &str) -> Result { + let z_part = did + .strip_prefix("did:key:z") + .ok_or_else(|| anyhow!("invalid did:key format: {}", did))?; + let decoded = bs58::decode(z_part) + .into_vec() + .context("invalid base58 in did:key")?; + if decoded.len() != 34 || decoded[0..2] != ED25519_MULTICODEC { + return Err(anyhow!("not an Ed25519 did:key (bad multicodec prefix)")); + } + let arr: [u8; 32] = decoded[2..] + .try_into() + .expect("length checked above"); + VerifyingKey::from_bytes(&arr).context("invalid Ed25519 public key in did:key") +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + + #[test] + fn roundtrip() { + let key = SigningKey::from_bytes(&[3u8; 32]).verifying_key(); + let did = did_key_for_ed25519(&key); + assert!(did.starts_with("did:key:z6Mk"), "got {}", did); + let back = ed25519_pubkey_from_did_key(&did).unwrap(); + assert_eq!(key.as_bytes(), back.as_bytes()); + } + + #[test] + fn rejects_non_ed25519() { + assert!(ed25519_pubkey_from_did_key("did:key:zQ3shazz").is_err()); + assert!(ed25519_pubkey_from_did_key("not-a-did").is_err()); + } +} diff --git a/core/archipelago/src/trust/mod.rs b/core/archipelago/src/trust/mod.rs new file mode 100644 index 00000000..0bf92b41 --- /dev/null +++ b/core/archipelago/src/trust/mod.rs @@ -0,0 +1,23 @@ +//! Authenticity layer for the DHT distribution plan (Phase 0). +//! +//! Content addressing (SHA-256 today, BLAKE3 later) proves downloaded bytes are +//! *intact*. It does not prove they were *authorized*. This module adds the +//! missing half: detached Ed25519 signatures over canonical JSON, verified +//! against a pinned **release-root** trust anchor. +//! +//! Layout: +//! * [`anchor`] — the pinned release-root public key (+ env override). +//! * [`canonical`] — deterministic JSON serialization for signing. +//! * [`did`] — `did:key` <-> Ed25519 public key. +//! * [`signed_doc`]— detached sign/verify over a signed document. +//! +//! The release-root *private* key is publisher-only and derived in the signing +//! ceremony via [`crate::seed::derive_release_root_ed25519`]; fleet nodes only +//! ever hold the public key. + +pub mod anchor; +pub mod canonical; +pub mod did; +pub mod signed_doc; + +pub use signed_doc::{verify_detached, SignatureStatus}; diff --git a/core/archipelago/src/trust/signed_doc.rs b/core/archipelago/src/trust/signed_doc.rs new file mode 100644 index 00000000..07e1bc88 --- /dev/null +++ b/core/archipelago/src/trust/signed_doc.rs @@ -0,0 +1,191 @@ +//! Detached Ed25519 signatures over canonical JSON documents. +//! +//! A *signed document* is any JSON object carrying two reserved top-level +//! fields: +//! +//! * `signed_by` — the signer's `did:key` (Ed25519), e.g. the release-root. +//! * `signature` — hex-encoded Ed25519 signature over the canonical JSON of +//! the document with **both** reserved fields removed. +//! +//! Removing the fields before canonicalizing makes the signature *detached*: +//! the signer signs the payload, then attaches the proof, without a +//! chicken-and-egg dependency on the signature's own bytes. +//! +//! Authenticity ≠ integrity. Content addressing (SHA-256/BLAKE3 in the +//! manifest) proves the bytes are intact; this signature proves *we authorized +//! them*. The DHT plan requires both. + +use anyhow::{anyhow, bail, Context, Result}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde_json::Value; + +use super::anchor; +use super::canonical; +use super::did; + +pub const SIGNATURE_FIELD: &str = "signature"; +pub const SIGNED_BY_FIELD: &str = "signed_by"; + +/// Outcome of inspecting a document for a detached signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureStatus { + /// No `signature` field present. Caller decides whether to accept + /// (during the migration window we still accept unsigned documents). + Unsigned, + /// Signature verified. `anchored` is true when `signed_by` matched the + /// pinned release-root anchor (full authenticity); false means the + /// signature is internally consistent but the signer key is not yet + /// pinned, so it only proves the document wasn't tampered relative to its + /// own claimed key. + Verified { signer_did: String, anchored: bool }, +} + +/// Verify a document's detached signature *if present*. +/// +/// Returns `Ok(Unsigned)` when there is no signature. Returns `Ok(Verified)` +/// when a present signature checks out. Returns `Err` when a signature is +/// present but malformed, fails verification, or names a signer that +/// contradicts the pinned anchor — callers MUST reject the document on `Err`. +pub fn verify_detached(doc: &Value) -> Result { + let obj = doc + .as_object() + .ok_or_else(|| anyhow!("signed document must be a JSON object"))?; + + let signature_hex = match obj.get(SIGNATURE_FIELD) { + None | Some(Value::Null) => return Ok(SignatureStatus::Unsigned), + Some(Value::String(s)) => s, + Some(_) => bail!("`{}` must be a string", SIGNATURE_FIELD), + }; + + let signed_by = obj + .get(SIGNED_BY_FIELD) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("signed document has `{}` but no `{}`", SIGNATURE_FIELD, SIGNED_BY_FIELD))?; + + let signer = did::ed25519_pubkey_from_did_key(signed_by) + .with_context(|| format!("invalid `{}` did:key", SIGNED_BY_FIELD))?; + + // If the fleet has a pinned release-root, the signer MUST be it. This is + // what stops a mirror from swapping in its own keypair and re-signing. + let anchored = match anchor::release_root_pubkey() { + Some(pinned) => { + if pinned != signer { + bail!("signed_by does not match the pinned release-root anchor"); + } + true + } + None => false, + }; + + let signature = parse_signature_hex(signature_hex)?; + let preimage = signing_preimage(obj)?; + signer + .verify_strict(&preimage, &signature) + .map_err(|_| anyhow!("release-root signature verification failed"))?; + + Ok(SignatureStatus::Verified { + signer_did: signed_by.to_string(), + anchored, + }) +} + +/// Produce a detached signature for `payload` (the document WITHOUT the +/// reserved fields). Used by the signing ceremony and round-trip tests. +/// Returns `(signature_hex, signed_by_did)`. +pub fn sign_detached(key: &SigningKey, payload: &Value) -> Result<(String, String)> { + let obj = payload + .as_object() + .ok_or_else(|| anyhow!("payload must be a JSON object"))?; + if obj.contains_key(SIGNATURE_FIELD) || obj.contains_key(SIGNED_BY_FIELD) { + bail!("payload must not already contain reserved signature fields"); + } + let preimage = signing_preimage(obj)?; + let signature = key.sign(&preimage); + let did = did::did_key_for_ed25519(&key.verifying_key()); + Ok((hex::encode(signature.to_bytes()), did)) +} + +/// Canonical bytes the signature covers: the object minus the reserved fields. +fn signing_preimage(obj: &serde_json::Map) -> Result> { + let mut payload = obj.clone(); + payload.remove(SIGNATURE_FIELD); + payload.remove(SIGNED_BY_FIELD); + let value = Value::Object(payload); + if canonical::contains_float(&value) { + bail!("signed documents must not contain floating-point numbers"); + } + Ok(canonical::to_canonical_bytes(&value)) +} + +fn parse_signature_hex(s: &str) -> Result { + let bytes = hex::decode(s).context("signature is not valid hex")?; + let arr: [u8; 64] = bytes + .as_slice() + .try_into() + .map_err(|_| anyhow!("signature must be 64 bytes, got {}", bytes.len()))?; + Ok(Signature::from_bytes(&arr)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn test_key() -> SigningKey { + SigningKey::from_bytes(&[7u8; 32]) + } + + fn sign_into(key: &SigningKey, mut doc: Value) -> Value { + let (sig, did) = sign_detached(key, &doc).unwrap(); + let obj = doc.as_object_mut().unwrap(); + obj.insert(SIGNED_BY_FIELD.into(), json!(did)); + obj.insert(SIGNATURE_FIELD.into(), json!(sig)); + doc + } + + #[test] + fn unsigned_document_reports_unsigned() { + let doc = json!({"schema": 1, "apps": {}}); + assert_eq!(verify_detached(&doc).unwrap(), SignatureStatus::Unsigned); + } + + #[test] + fn roundtrip_verifies() { + let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42})); + match verify_detached(&signed).unwrap() { + // No anchor pinned in the default test build → anchored == false. + SignatureStatus::Verified { anchored, .. } => assert!(!anchored), + other => panic!("expected Verified, got {:?}", other), + } + } + + #[test] + fn signature_survives_key_reordering() { + // Re-emitting the document with shuffled keys must not break the sig. + let signed = sign_into(&test_key(), json!({"b": 2, "a": 1})); + let reparsed: Value = + serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap(); + assert!(matches!( + verify_detached(&reparsed).unwrap(), + SignatureStatus::Verified { .. } + )); + } + + #[test] + fn tampered_payload_is_rejected() { + let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42})); + signed.as_object_mut().unwrap().insert("n".into(), json!(43)); + assert!(verify_detached(&signed).is_err()); + } + + #[test] + fn missing_signed_by_is_rejected() { + let doc = json!({"schema": 1, "signature": "00"}); + assert!(verify_detached(&doc).is_err()); + } + + #[test] + fn float_payload_cannot_be_signed() { + assert!(sign_detached(&test_key(), &json!({"x": 1.5})).is_err()); + } +}