Skip to main content

ant_protocol/payment/
commitment.rs

1//! The signed storage commitment — shared between node and client (ADR-0004).
2//!
3//! ADR-0004 makes a quote's price a function of the node's audited storage
4//! commitment, and requires the **client** to fully verify that commitment
5//! before paying ("the client pays nothing it cannot resolve" — the ceiling's
6//! load-bearing wall). To do that the client needs the commitment type, its
7//! pin (hash), and its signature/peer-binding check — exactly the pieces here.
8//!
9//! This is the **single source of truth** for the commitment wire type and its
10//! verification: `ant-node` re-exports [`StorageCommitment`], [`commitment_hash`],
11//! [`verify_commitment_signature`], and [`MAX_COMMITMENT_KEY_COUNT`] from this
12//! module so the node (gossip/audit) and the client (resolve-before-pay) can
13//! never disagree on what a valid commitment is or what pin it hashes to.
14//!
15//! Only the *verification* surface lives here. The Merkle tree, inclusion
16//! paths, and signing live in `ant-node` (the responder/auditor own those);
17//! the client never builds or signs a commitment, it only verifies one.
18
19use blake3::Hasher;
20use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant};
21use serde::{Deserialize, Serialize};
22
23/// Domain-separation tag for the commitment signature.
24///
25/// Signed payload is verified under this context tag.
26pub const DOMAIN_COMMITMENT: &[u8] = b"autonomi.ant.replication.storage_commitment.v1";
27
28/// Domain-separation tag for the auditor's pin: BLAKE3 over (this tag ||
29/// canonical commitment blob).
30pub const DOMAIN_COMMITMENT_HASH: &[u8] = b"autonomi.ant.replication.commitment_hash.v1";
31
32/// Maximum number of keys a single commitment may cover.
33///
34/// Bounds the Merkle path depth and responder-side tree memory. A node storing
35/// more keys than this would need to split its claim. The client rejects any
36/// quote whose `committed_key_count` exceeds this before paying, exactly as the
37/// node does.
38pub const MAX_COMMITMENT_KEY_COUNT: u32 = 1_000_000;
39
40/// Maximum serialized size of a single commitment sidecar blob (ADR-0004).
41///
42/// A well-formed `StorageCommitment` is ~5.3 KiB (root 32 + `key_count` 4 +
43/// `peer_id` 32 + pubkey 1952 + signature 3293 + serde framing). 8 KiB leaves
44/// generous headroom while bounding the deserialize/verify work a malicious
45/// quote responder or client can force on the hot verification path. A sidecar
46/// larger than this is rejected before any parse attempt.
47pub const MAX_COMMITMENT_SIDECAR_BYTES: usize = 8 * 1024;
48
49/// Signed storage commitment.
50///
51/// Piggybacked on neighbour-sync gossip and shipped alongside a quote (ADR-0004).
52/// The signature commits to the Merkle root, key count, sender peer ID, **and
53/// the sender's ML-DSA-65 public key** under [`DOMAIN_COMMITMENT`].
54///
55/// Embedding the public key lets any receiver (including the paying client)
56/// verify the signature without an external `PeerId → MlDsaPublicKey` lookup.
57/// Binding the public key in the signed payload prevents a key-swap attack.
58///
59/// Wire size ≈ 5.3 KiB (root 32 B + `key_count` 4 B + `peer_id` 32 B + pubkey
60/// 1952 B + signature 3293 B).
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct StorageCommitment {
63    /// Merkle root over the responder's claimed keys.
64    pub root: [u8; 32],
65    /// Number of leaves committed over.
66    pub key_count: u32,
67    /// Sender peer ID, bound to the signature.
68    pub sender_peer_id: [u8; 32],
69    /// Sender's ML-DSA-65 public key bytes (1952 bytes). Embedded so receivers
70    /// can verify the signature without a separate pubkey directory. Bound by
71    /// the signature.
72    pub sender_public_key: Vec<u8>,
73    /// ML-DSA-65 signature over canonical commitment fields. 3293 bytes.
74    pub signature: Vec<u8>,
75}
76
77/// The pin: `BLAKE3(DOMAIN_COMMITMENT_HASH || postcard(commitment))`.
78///
79/// Equal commitments produce equal hashes; any change to `root`, `key_count`,
80/// peer ID, pubkey, or signature changes the hash. A quote pins a commitment by
81/// this value; resolving the quote means finding a commitment that hashes to
82/// the pin.
83///
84/// # Errors
85///
86/// Returns `None` only if postcard fails to serialize the commitment — not
87/// reachable for a well-formed ML-DSA-65 commitment. Callers treat `None` as a
88/// malformed commitment and drop it.
89#[must_use]
90pub fn commitment_hash(c: &StorageCommitment) -> Option<[u8; 32]> {
91    let serialized = postcard::to_allocvec(c).ok()?;
92    let mut h = Hasher::new();
93    h.update(DOMAIN_COMMITMENT_HASH);
94    h.update(&serialized);
95    Some(*h.finalize().as_bytes())
96}
97
98/// Canonical bytes the ML-DSA signature covers: the commitment fields minus the
99/// signature itself.
100///
101/// `sender_public_key` is length-prefixed and included so an adversary cannot
102/// keep the body and re-sign under a different key.
103#[must_use]
104pub fn commitment_signed_payload(
105    root: &[u8; 32],
106    key_count: u32,
107    sender_peer_id: &[u8; 32],
108    sender_public_key: &[u8],
109) -> Vec<u8> {
110    let mut v = Vec::with_capacity(32 + 4 + 32 + 4 + sender_public_key.len());
111    v.extend_from_slice(root);
112    v.extend_from_slice(&key_count.to_le_bytes());
113    v.extend_from_slice(sender_peer_id);
114    let pk_len = u32::try_from(sender_public_key.len()).unwrap_or(u32::MAX);
115    v.extend_from_slice(&pk_len.to_le_bytes());
116    v.extend_from_slice(sender_public_key);
117    v
118}
119
120/// Verify a commitment's ML-DSA-65 signature against its **embedded** public
121/// key. Does NOT check the peer binding (`BLAKE3(pubkey) == sender_peer_id`) —
122/// callers that need it (the client, the node) check it separately so the same
123/// function serves both the "trust the embedded key" and "bind to a peer" uses.
124#[must_use]
125pub fn verify_commitment_signature(c: &StorageCommitment) -> bool {
126    let Ok(public_key) = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, &c.sender_public_key)
127    else {
128        return false;
129    };
130    let payload = commitment_signed_payload(
131        &c.root,
132        c.key_count,
133        &c.sender_peer_id,
134        &c.sender_public_key,
135    );
136    let Ok(sig) = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &c.signature) else {
137        return false;
138    };
139    ml_dsa_65()
140        .verify_with_context(&public_key, &payload, &sig, DOMAIN_COMMITMENT)
141        .unwrap_or(false)
142}
143
144#[cfg(test)]
145#[allow(clippy::unwrap_used, clippy::expect_used)]
146mod tests {
147    use super::*;
148    use saorsa_pqc::api::sig::ml_dsa_65;
149
150    /// Build a genuinely-signed commitment (fresh ML-DSA-65 keypair, signed over
151    /// the exact `commitment_signed_payload` under `DOMAIN_COMMITMENT`) — the same
152    /// thing the node produces. Returns the commitment and the keypair's public
153    /// bytes so tamper tests can key-swap.
154    fn signed_commitment(root: [u8; 32], key_count: u32, peer_id: [u8; 32]) -> StorageCommitment {
155        let (pk, sk) = ml_dsa_65().generate_keypair().unwrap();
156        let pk_bytes = pk.to_bytes();
157        let payload = commitment_signed_payload(&root, key_count, &peer_id, &pk_bytes);
158        let sig = ml_dsa_65()
159            .sign_with_context(&sk, &payload, DOMAIN_COMMITMENT)
160            .unwrap();
161        StorageCommitment {
162            root,
163            key_count,
164            sender_peer_id: peer_id,
165            sender_public_key: pk_bytes,
166            signature: sig.to_bytes(),
167        }
168    }
169
170    /// A malformed commitment (bad pubkey / signature) fails verification
171    /// rather than panicking — the client runs this on untrusted bytes.
172    #[test]
173    fn verify_rejects_malformed_without_panic() {
174        let c = StorageCommitment {
175            root: [0u8; 32],
176            key_count: 1,
177            sender_peer_id: [0u8; 32],
178            sender_public_key: vec![0u8; 10],
179            signature: vec![0u8; 10],
180        };
181        assert!(!verify_commitment_signature(&c));
182        // hash is still computable over any well-formed struct.
183        assert!(commitment_hash(&c).is_some());
184    }
185
186    /// The pin is deterministic and changes when any field changes.
187    #[test]
188    fn commitment_hash_is_deterministic_and_field_sensitive() {
189        let c = StorageCommitment {
190            root: [1u8; 32],
191            key_count: 5,
192            sender_peer_id: [2u8; 32],
193            sender_public_key: vec![3u8; 20],
194            signature: vec![4u8; 20],
195        };
196        let h1 = commitment_hash(&c).unwrap();
197        let h2 = commitment_hash(&c).unwrap();
198        assert_eq!(h1, h2, "same commitment -> same pin");
199
200        let mut c2 = c.clone();
201        c2.key_count = 6;
202        assert_ne!(
203            commitment_hash(&c2).unwrap(),
204            h1,
205            "changing key_count must change the pin"
206        );
207    }
208
209    /// A correctly-signed commitment verifies. Without this, a regression that
210    /// made `verify_commitment_signature` always return `false` (or ignore the
211    /// signature) would pass every other test in this module.
212    #[test]
213    fn verify_accepts_a_correctly_signed_commitment() {
214        let c = signed_commitment([7u8; 32], 42, [9u8; 32]);
215        assert!(
216            verify_commitment_signature(&c),
217            "a genuinely signed commitment must verify"
218        );
219    }
220
221    /// The signature covers `root`, `key_count`, `peer_id`, and the embedded
222    /// pubkey — mutating any of them after signing must fail verification. This
223    /// is the ADR-0004 "the fields are covered by the signature" invariant.
224    #[test]
225    fn verify_rejects_any_field_tampered_after_signing() {
226        // key_count
227        let mut c = signed_commitment([1u8; 32], 100, [2u8; 32]);
228        c.key_count = c.key_count.wrapping_add(1);
229        assert!(
230            !verify_commitment_signature(&c),
231            "tampered key_count must fail"
232        );
233
234        // root
235        let mut c = signed_commitment([1u8; 32], 100, [2u8; 32]);
236        c.root[0] ^= 0xff;
237        assert!(!verify_commitment_signature(&c), "tampered root must fail");
238
239        // sender_peer_id
240        let mut c = signed_commitment([1u8; 32], 100, [2u8; 32]);
241        c.sender_peer_id[0] ^= 0xff;
242        assert!(
243            !verify_commitment_signature(&c),
244            "tampered peer_id must fail"
245        );
246
247        // key-swap: keep the body + signature, swap in a different valid pubkey.
248        // The pubkey is bound into the signed payload, so this must fail.
249        let mut c = signed_commitment([1u8; 32], 100, [2u8; 32]);
250        let (other_pk, _) = ml_dsa_65().generate_keypair().unwrap();
251        c.sender_public_key = other_pk.to_bytes();
252        assert!(
253            !verify_commitment_signature(&c),
254            "swapping the embedded pubkey must fail"
255        );
256    }
257}