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:
parent
ee46a856de
commit
0fef808671
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
71
core/archipelago/src/trust/anchor.rs
Normal file
71
core/archipelago/src/trust/anchor.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
87
core/archipelago/src/trust/canonical.rs
Normal file
87
core/archipelago/src/trust/canonical.rs
Normal 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]})));
|
||||
}
|
||||
}
|
||||
56
core/archipelago/src/trust/did.rs
Normal file
56
core/archipelago/src/trust/did.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
23
core/archipelago/src/trust/mod.rs
Normal file
23
core/archipelago/src/trust/mod.rs
Normal 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};
|
||||
191
core/archipelago/src/trust/signed_doc.rs
Normal file
191
core/archipelago/src/trust/signed_doc.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user