Skip to main content

metamorphic_log/
commitment.rs

1//! Layer-3b: **SHA3-512 hash-based commitments** binding an index to a value.
2//!
3//! A CONIKS directory does not store a raw `(identity, value)` pair at a tree
4//! position — it stores a *commitment* to the value. The commitment is
5//! **binding** (the directory cannot later open it to a different value) and
6//! **hiding** (the committed bytes reveal nothing about the value without the
7//! opening). A lookup proof reveals the value and its opening so the recipient
8//! can check the commitment binds to exactly that value.
9//!
10//! This is the **post-quantum** half of the privacy layer: the binding property
11//! rests on SHA3-512 collision resistance (NIST Category 5), independent of the
12//! classical VRF. Even if the index-privacy VRF were broken, commitments would
13//! still bind.
14//!
15//! ## Construction (stable wire format — reproduce exactly for parity)
16//!
17//! ```text
18//! opening    = 32 random bytes (the per-commitment blinding nonce)
19//! commitment = SHA3-512_with_context(context, opening (32) || value)
20//! ```
21//!
22//! The fixed-length 32-byte opening sits first, so the `(opening, value)`
23//! boundary is unambiguous without a length prefix, and the
24//! [`metamorphic_crypto::hash::sha3_512_with_context`] framing binds the
25//! commitment to a versioned `context` label (CONIKS passes a per-namespace
26//! label, so commitments never collide or cross-verify between namespaces).
27//!
28//! Hiding holds because the 32-byte opening is high-entropy and secret until
29//! revealed; binding holds because finding two `(opening, value)` pairs with the
30//! same SHA3-512 digest is infeasible.
31
32use metamorphic_crypto::hash::sha3_512_with_context;
33
34use crate::error::{Error, Result};
35
36/// Length of a commitment opening (blinding nonce), in bytes.
37pub const COMMITMENT_OPENING_LEN: usize = 32;
38/// Length of a commitment digest, in bytes (a SHA3-512 output).
39pub const COMMITMENT_LEN: usize = 64;
40
41/// A hiding, binding commitment to a value (a 64-byte SHA3-512 digest).
42#[derive(Clone, PartialEq, Eq, Hash)]
43pub struct Commitment([u8; COMMITMENT_LEN]);
44
45/// The opening (blinding nonce) for a [`Commitment`]. Revealing it, together
46/// with the value, lets anyone re-derive and check the commitment.
47#[derive(Clone, PartialEq, Eq)]
48pub struct Opening([u8; COMMITMENT_OPENING_LEN]);
49
50impl Commitment {
51    /// Wrap a raw 64-byte commitment digest.
52    #[must_use]
53    pub fn from_bytes(bytes: [u8; COMMITMENT_LEN]) -> Self {
54        Self(bytes)
55    }
56
57    /// The raw 64-byte commitment digest.
58    #[must_use]
59    pub fn as_bytes(&self) -> &[u8; COMMITMENT_LEN] {
60        &self.0
61    }
62}
63
64impl core::fmt::Debug for Commitment {
65    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66        write!(f, "Commitment({:02x}{:02x}..)", self.0[0], self.0[1])
67    }
68}
69
70impl Opening {
71    /// Wrap a raw 32-byte opening.
72    #[must_use]
73    pub fn from_bytes(bytes: [u8; COMMITMENT_OPENING_LEN]) -> Self {
74        Self(bytes)
75    }
76
77    /// The raw 32-byte opening.
78    #[must_use]
79    pub fn as_bytes(&self) -> &[u8; COMMITMENT_OPENING_LEN] {
80        &self.0
81    }
82}
83
84// The opening is a blinding nonce, not long-term key material, but it is secret
85// until deliberately revealed in a proof, so keep it out of `Debug` output.
86impl core::fmt::Debug for Opening {
87    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
88        f.write_str("Opening(..)")
89    }
90}
91
92/// Derive a commitment from a value and an explicit opening (deterministic).
93///
94/// Use this to recompute a commitment during verification, or when the opening
95/// is generated elsewhere. To create a fresh commitment, prefer [`commit`],
96/// which samples the opening from the OS CSPRNG.
97#[must_use]
98pub fn commit_with_opening(context: &str, value: &[u8], opening: &Opening) -> Commitment {
99    let mut framed = Vec::with_capacity(COMMITMENT_OPENING_LEN + value.len());
100    framed.extend_from_slice(opening.as_bytes());
101    framed.extend_from_slice(value);
102    Commitment(sha3_512_with_context(context, &framed))
103}
104
105/// Create a fresh commitment to `value`, sampling a random 32-byte opening from
106/// the OS CSPRNG. Returns `(commitment, opening)`; keep the opening to reveal in
107/// a lookup proof.
108#[must_use]
109pub fn commit(context: &str, value: &[u8]) -> (Commitment, Opening) {
110    let mut nonce = [0u8; COMMITMENT_OPENING_LEN];
111    getrandom::getrandom(&mut nonce).expect("OS CSPRNG unavailable");
112    let opening = Opening(nonce);
113    let commitment = commit_with_opening(context, value, &opening);
114    (commitment, opening)
115}
116
117/// Check that `commitment` opens to `value` under `opening` and `context`.
118///
119/// # Errors
120/// Returns [`Error::CommitmentMismatch`] if the recomputed commitment does not
121/// equal `commitment` (wrong value, wrong opening, or wrong context).
122pub fn verify_commitment(
123    context: &str,
124    commitment: &Commitment,
125    value: &[u8],
126    opening: &Opening,
127) -> Result<()> {
128    if &commit_with_opening(context, value, opening) == commitment {
129        Ok(())
130    } else {
131        Err(Error::CommitmentMismatch)
132    }
133}
134
135#[cfg(all(test, not(target_arch = "wasm32")))]
136mod tests {
137    use super::*;
138
139    const CTX: &str = "acme/coniks-commitment/v1";
140
141    #[test]
142    fn commit_then_verify_opens() {
143        let (c, o) = commit(CTX, b"public key bytes");
144        assert!(verify_commitment(CTX, &c, b"public key bytes", &o).is_ok());
145    }
146
147    #[test]
148    fn wrong_value_does_not_open() {
149        let (c, o) = commit(CTX, b"value-a");
150        assert_eq!(
151            verify_commitment(CTX, &c, b"value-b", &o),
152            Err(Error::CommitmentMismatch)
153        );
154    }
155
156    #[test]
157    fn wrong_opening_does_not_open() {
158        let (c, _o) = commit(CTX, b"value");
159        let other = Opening::from_bytes([0u8; COMMITMENT_OPENING_LEN]);
160        assert_eq!(
161            verify_commitment(CTX, &c, b"value", &other),
162            Err(Error::CommitmentMismatch)
163        );
164    }
165
166    #[test]
167    fn different_context_does_not_open() {
168        // Cross-namespace separation: a commitment made under one namespace
169        // label must not verify under another.
170        let (c, o) = commit(CTX, b"value");
171        assert_eq!(
172            verify_commitment("other/coniks-commitment/v1", &c, b"value", &o),
173            Err(Error::CommitmentMismatch)
174        );
175    }
176
177    #[test]
178    fn fresh_commitments_are_hiding_across_calls() {
179        // Two commitments to the same value use independent random openings and
180        // therefore differ (so the digest leaks nothing about the value).
181        let (c1, _) = commit(CTX, b"same");
182        let (c2, _) = commit(CTX, b"same");
183        assert_ne!(c1, c2);
184    }
185
186    #[test]
187    fn deterministic_for_fixed_opening() {
188        let o = Opening::from_bytes([5u8; COMMITMENT_OPENING_LEN]);
189        assert_eq!(
190            commit_with_opening(CTX, b"v", &o),
191            commit_with_opening(CTX, b"v", &o)
192        );
193    }
194
195    #[test]
196    fn matches_documented_framing() {
197        let o = Opening::from_bytes([3u8; COMMITMENT_OPENING_LEN]);
198        let value = b"explicit framing check";
199        let mut framed = Vec::new();
200        framed.extend_from_slice(o.as_bytes());
201        framed.extend_from_slice(value);
202        let expected = sha3_512_with_context(CTX, &framed);
203        assert_eq!(commit_with_opening(CTX, value, &o).as_bytes(), &expected);
204    }
205
206    use proptest::prelude::*;
207
208    proptest! {
209        #[test]
210        fn commit_verify_roundtrip(value: Vec<u8>, nonce: [u8; 32]) {
211            let opening = Opening::from_bytes(nonce);
212            let c = commit_with_opening(CTX, &value, &opening);
213            prop_assert!(verify_commitment(CTX, &c, &value, &opening).is_ok());
214        }
215
216        #[test]
217        fn distinct_values_distinct_commitments(a: Vec<u8>, b: Vec<u8>, nonce: [u8; 32]) {
218            prop_assume!(a != b);
219            let opening = Opening::from_bytes(nonce);
220            prop_assert_ne!(
221                commit_with_opening(CTX, &a, &opening),
222                commit_with_opening(CTX, &b, &opening)
223            );
224        }
225    }
226}