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.
This commit is contained in:
archipelago 2026-06-16 11:22:24 -04:00
parent ee46a856de
commit 0fef808671
6 changed files with 482 additions and 0 deletions

View File

@ -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<SigningKey> {
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<SigningKey> {
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<SigningKey> {
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"
);
}
}

View File

@ -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<VerifyingKey> {
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<VerifyingKey> {
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());
}
}

View File

@ -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<u8> {
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<String, Value> = 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]})));
}
}

View File

@ -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<base58btc(0xed01 || 32-byte-pubkey)>`
//! (`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<VerifyingKey> {
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());
}
}

View File

@ -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};

View File

@ -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<SignatureStatus> {
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<String, Value>) -> Result<Vec<u8>> {
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<Signature> {
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());
}
}