Skip to main content

confium_crypto_zk/
zk_key_possession.rs

1//! Schnorr proof of possession of a P-256 signing key.
2//!
3//! Proves knowledge of the discrete logarithm `x` of a public key
4//! `X = x·G`, bound to an arbitrary context string through the
5//! Fiat-Shamir challenge. This is the standard rogue-key defense:
6//! key-aggregation schemes (MuSig-style multi-signatures, threshold
7//! enrollment) must require each contributor to prove possession of
8//! the key it submits — otherwise a malicious contributor crafts a
9//! share such that the aggregate key is one it controls alone.
10//!
11//! The construction is a textbook Schnorr identification protocol
12//! made non-interactive with Fiat-Shamir. The statement (public key
13//! AND context) is bound into the challenge, so a proof does not
14//! transfer to another key or another context.
15//!
16//! For proving possession of an ECDSA *signature* without revealing
17//! it, see `zk_sig_possession` — that statement contains the
18//! coordinate check `x(R) ≡ r (mod n)`, which is a bit-decomposition
19//! relation no plain sigma-protocol can carry; it stays gated until a
20//! circuit-based construction lands.
21
22use getrandom::SysRng;
23use p256::ecdsa::{SigningKey, VerifyingKey};
24use p256::elliptic_curve::PrimeField;
25use p256::elliptic_curve::rand_core::Rng;
26use p256::elliptic_curve::rand_core::UnwrapErr;
27use p256::elliptic_curve::sec1::FromSec1Point;
28use p256::elliptic_curve::sec1::ToSec1Point;
29use p256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar};
30use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32
33/// A proof that the prover knows the signing key behind a public key.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct KeyPossessionProof {
36    /// The public key this proof attests to (hex, SEC1 compressed).
37    pub public_key_hex: String,
38    /// Commitment point `R = r·G` (hex, SEC1 compressed).
39    pub commitment_hex: String,
40    /// Response `z = r + c·x` (hex).
41    pub response_hex: String,
42    /// SHA-256 of the bound context (hex) — proofs do not transfer
43    /// across contexts.
44    pub context_hex: String,
45}
46
47/// Reduce 32 bytes to a scalar by rejection sampling with re-hash.
48/// Never falls back to a constant: a zero nonce leaks the secret in
49/// the response and a zero challenge accepts forgeries.
50fn reduce_to_scalar(mut bytes: [u8; 32]) -> Scalar {
51    loop {
52        if let Some(s) = Option::<Scalar>::from(Scalar::from_repr(FieldBytes::from(bytes))) {
53            return s;
54        }
55        let mut h = Sha256::new();
56        h.update(b"confium-scalar-reduce-v1");
57        h.update(bytes);
58        bytes = h.finalize().into();
59    }
60}
61
62fn hash_context(context: &[u8]) -> [u8; 32] {
63    let mut hasher = Sha256::new();
64    hasher.update(b"confium-key-possession-context-v1");
65    hasher.update(context);
66    hasher.finalize().into()
67}
68
69fn challenge(public_key: &[u8], context_hash: &[u8; 32], commitment: &[u8]) -> Scalar {
70    let mut hasher = Sha256::new();
71    hasher.update(b"confium-key-possession-v1");
72    hasher.update(public_key);
73    hasher.update(context_hash);
74    hasher.update(commitment);
75    let bytes: [u8; 32] = hasher.finalize().into();
76    reduce_to_scalar(bytes)
77}
78
79/// Prove possession of the signing key behind `signing_key`, bound to
80/// `context`. The signing key itself never leaves the prover.
81pub fn prove_key_possession(
82    signing_key: &SigningKey,
83    context: &[u8],
84) -> Result<KeyPossessionProof, String> {
85    let verifying = signing_key.verifying_key();
86    let pk_bytes = verifying.as_affine().to_sec1_point(true);
87    let pk_hex = hex::encode(pk_bytes.as_bytes());
88    let context_hash = hash_context(context);
89    let context_hex = hex::encode(context_hash);
90
91    // to_bytes returns the raw scalar — always canonical, no
92    // reduction path to get wrong.
93    let x = Option::<Scalar>::from(Scalar::from_repr(signing_key.to_bytes()))
94        .expect("signing key encodes a canonical scalar");
95
96    loop {
97        let mut nonce_bytes = [0u8; 32];
98        UnwrapErr(SysRng).fill_bytes(&mut nonce_bytes);
99        let nonce = reduce_to_scalar(nonce_bytes);
100        if nonce == Scalar::ZERO {
101            continue;
102        }
103
104        let commitment = (ProjectivePoint::GENERATOR * nonce).to_affine();
105        let commitment_bytes = commitment.to_sec1_point(true);
106        let c = challenge(
107            pk_bytes.as_bytes(),
108            &context_hash,
109            commitment_bytes.as_bytes(),
110        );
111
112        let response = nonce + c * x;
113        let response_bytes: [u8; 32] = response.to_repr().into();
114
115        return Ok(KeyPossessionProof {
116            public_key_hex: pk_hex,
117            commitment_hex: hex::encode(commitment_bytes.as_bytes()),
118            response_hex: hex::encode(response_bytes),
119            context_hex,
120        });
121    }
122}
123
124/// Verify a key-possession proof for `public_key` and `context`.
125pub fn verify_key_possession(
126    proof: &KeyPossessionProof,
127    context: &[u8],
128    public_key: &VerifyingKey,
129) -> bool {
130    let context_hash = hash_context(context);
131    if hex::encode(context_hash) != proof.context_hex {
132        return false;
133    }
134
135    let pk_bytes = public_key.as_affine().to_sec1_point(true);
136    if hex::encode(pk_bytes.as_bytes()) != proof.public_key_hex {
137        return false;
138    }
139
140    let commitment_bytes = match hex::decode(&proof.commitment_hex) {
141        Ok(b) => b,
142        Err(_) => return false,
143    };
144    let encoded = match p256::elliptic_curve::sec1::Sec1Point::<p256::NistP256>::from_bytes(
145        &commitment_bytes,
146    ) {
147        Ok(e) => e,
148        Err(_) => return false,
149    };
150    let commitment = match Option::<AffinePoint>::from(AffinePoint::from_sec1_point(&encoded)) {
151        Some(p) => p,
152        None => return false,
153    };
154
155    let response_bytes = match hex::decode(&proof.response_hex) {
156        Ok(b) => b,
157        Err(_) => return false,
158    };
159    if response_bytes.len() != 32 {
160        return false;
161    }
162    let arr: [u8; 32] = match response_bytes.as_slice().try_into() {
163        Ok(a) => a,
164        Err(_) => return false,
165    };
166    let response = match Option::<Scalar>::from(Scalar::from_repr(arr.into())) {
167        Some(s) => s,
168        None => return false,
169    };
170    if response == Scalar::ZERO {
171        return false;
172    }
173
174    let c = challenge(pk_bytes.as_bytes(), &context_hash, &commitment_bytes);
175    let pk_point = ProjectivePoint::from(*public_key.as_affine());
176
177    // z·G == R + c·X
178    let lhs = ProjectivePoint::GENERATOR * response;
179    let rhs = ProjectivePoint::from(commitment) + pk_point * c;
180    lhs == rhs
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use p256::elliptic_curve::Generate;
187
188    #[test]
189    fn honest_proof_round_trips() {
190        let signing = SigningKey::generate();
191        let vk = signing.verifying_key();
192        let proof = prove_key_possession(&signing, b"enroll signer 7").unwrap();
193        assert!(verify_key_possession(&proof, b"enroll signer 7", vk));
194    }
195
196    #[test]
197    fn proofs_differ_across_contexts() {
198        let signing = SigningKey::generate();
199        let p1 = prove_key_possession(&signing, b"context-a").unwrap();
200        let p2 = prove_key_possession(&signing, b"context-b").unwrap();
201        assert_ne!(p1.commitment_hex, p2.commitment_hex);
202        assert_ne!(p1.context_hex, p2.context_hex);
203    }
204
205    #[test]
206    fn proof_does_not_reveal_the_signing_key() {
207        let signing = SigningKey::generate();
208        let proof = prove_key_possession(&signing, b"context").unwrap();
209        let key_bytes = signing.to_bytes();
210        let json = serde_json::to_string(&proof).unwrap();
211        assert!(!json.contains(&hex::encode(key_bytes)));
212    }
213}
214
215#[cfg(test)]
216mod adversarial_tests {
217    //! Paired rejects-forgery tests for proof verification.
218
219    use super::*;
220    use p256::elliptic_curve::Generate;
221
222    #[test]
223    fn verify_rejects_tampered_response() {
224        let signing = SigningKey::generate();
225        let vk = signing.verifying_key();
226        let mut proof = prove_key_possession(&signing, b"ctx").unwrap();
227
228        let mut resp = hex::decode(&proof.response_hex).unwrap();
229        resp[0] ^= 0x01;
230        proof.response_hex = resp.iter().map(|b| format!("{b:02x}")).collect();
231        assert!(!verify_key_possession(&proof, b"ctx", vk));
232    }
233
234    #[test]
235    fn verify_rejects_tampered_commitment() {
236        let signing = SigningKey::generate();
237        let vk = signing.verifying_key();
238        let mut proof = prove_key_possession(&signing, b"ctx").unwrap();
239
240        // Swap the commitment for a different valid point: the
241        // challenge is bound to the original, so the response cannot
242        // satisfy the equation under the new commitment.
243        let other_key = SigningKey::generate();
244        let other = other_key.verifying_key();
245        proof.commitment_hex = hex::encode(other.as_affine().to_sec1_point(true).as_bytes());
246        assert!(!verify_key_possession(&proof, b"ctx", vk));
247    }
248
249    #[test]
250    fn verify_rejects_proof_for_a_different_context() {
251        let signing = SigningKey::generate();
252        let vk = signing.verifying_key();
253        let proof = prove_key_possession(&signing, b"original").unwrap();
254        // Valid proof, wrong statement.
255        assert!(!verify_key_possession(&proof, b"other", vk));
256    }
257
258    #[test]
259    fn verify_rejects_proof_under_a_different_key() {
260        let signing = SigningKey::generate();
261        let other = SigningKey::generate();
262        let proof = prove_key_possession(&signing, b"ctx").unwrap();
263        assert!(!verify_key_possession(
264            &proof,
265            b"ctx",
266            other.verifying_key()
267        ));
268    }
269
270    #[test]
271    fn verify_rejects_zero_response() {
272        let signing = SigningKey::generate();
273        let vk = signing.verifying_key();
274        let mut proof = prove_key_possession(&signing, b"ctx").unwrap();
275        proof.response_hex = hex::encode([0u8; 32]);
276        assert!(!verify_key_possession(&proof, b"ctx", vk));
277    }
278}