Skip to main content

pg_core/
challenge.rs

1//! Proving possession of a PKG-issued signing key.
2//!
3//! A holder of a [`SigningKeyExt`] signs a challenge chosen by the party that
4//! wants the proof; that party verifies it with the [`VerifyingKey`] and the
5//! [`Policy`] whose identity it expects. Nothing here touches a container: it
6//! is a live proof that whoever is talking holds the signing key belonging to
7//! an identity, not a statement about data at rest.
8//!
9//! The signing key that signs a challenge is the same one that signs container
10//! headers, so a challenge signature must never be mistakable for a header
11//! signature. [`CHALLENGE_DOMAIN`] is what keeps the two apart, and
12//! [`sign_challenge`] applies it itself: a verifier hands over a challenge and
13//! a context, never the leading bytes of the signed message. Were the domain
14//! separator an argument, a malicious verifier could pass a serialized header
15//! as the "challenge" and get back a signature valid on a container it wrote.
16//!
17//! `context` names what the proof is for — an endpoint, an upload id, a
18//! session. It is signed alongside the challenge so a proof collected for one
19//! purpose does not replay into another.
20//!
21//! ```rust
22//! use pg_core::challenge::{sign_challenge, verify_challenge};
23//! # use pg_core::test::TestSetup;
24//!
25//! let mut rng = rand::thread_rng();
26//! # let setup = TestSetup::new(&mut rng);
27//! let signing_key = &setup.signing_keys[0];
28//!
29//! // The verifier picks the challenge; the signer never chooses it.
30//! let challenge = b"32 random bytes from the verifier";
31//!
32//! let sig = sign_challenge(signing_key, "cryptify/upload", challenge, &mut rng);
33//!
34//! assert!(verify_challenge(
35//!     &setup.ibs_pk,
36//!     &signing_key.policy,
37//!     "cryptify/upload",
38//!     challenge,
39//!     &sig,
40//! ));
41//! ```
42
43use alloc::vec::Vec;
44
45use crate::artifacts::{SigningKeyExt, VerifyingKey};
46use crate::identity::Policy;
47
48use ibs::gg::{Signature, Signer, Verifier};
49use rand::{CryptoRng, RngCore};
50
51/// Domain separator for upload-possession challenges. Applied by the
52/// signer, never taken from the verifier's input.
53pub const CHALLENGE_DOMAIN: &[u8] = b"postguard/challenge/v1";
54
55/// Builds the message a challenge signature is made over.
56///
57/// Both variable-length parts are length-prefixed rather than concatenated
58/// raw, so exactly one `(context, challenge)` pair maps to any message. Plain
59/// concatenation would make `("ab", "c")` and `("a", "bc")` the same bytes,
60/// which turns a proof collected under one context into a proof under another.
61fn challenge_message(context: &str, challenge: &[u8]) -> Vec<u8> {
62    let mut msg = Vec::with_capacity(
63        CHALLENGE_DOMAIN.len() + 2 * core::mem::size_of::<u64>() + context.len() + challenge.len(),
64    );
65
66    msg.extend_from_slice(CHALLENGE_DOMAIN);
67    msg.extend_from_slice(&(context.len() as u64).to_be_bytes());
68    msg.extend_from_slice(context.as_bytes());
69    msg.extend_from_slice(&(challenge.len() as u64).to_be_bytes());
70    msg.extend_from_slice(challenge);
71
72    msg
73}
74
75/// Signs a verifier-chosen challenge, proving possession of `key`.
76///
77/// [`CHALLENGE_DOMAIN`] is prepended here and cannot be opted out of, so the
78/// result is never a valid signature over anything else PostGuard signs — see
79/// the module documentation.
80///
81/// # Arguments
82///
83/// * `key`       - The signing key to prove possession of.
84/// * `context`   - What the proof is for, e.g. an endpoint or an upload id.
85/// * `challenge` - The bytes chosen by the verifier.
86/// * `rng`       - A cryptographically secure random number generator.
87pub fn sign_challenge<R: RngCore + CryptoRng>(
88    key: &SigningKeyExt,
89    context: &str,
90    challenge: &[u8],
91    rng: &mut R,
92) -> Signature {
93    Signer::new()
94        .chain(challenge_message(context, challenge))
95        .sign(&key.key.0, rng)
96}
97
98/// Verifies a challenge signature against the identity derived from `pol`.
99///
100/// Returns `false` for a signature that does not verify, and for a policy no
101/// identity can be derived from.
102///
103/// The identity comes from [`Policy::derive_ibs`], which canonicalizes
104/// attribute values. So this answers "does the signer hold the key for the
105/// identity this policy derives to", not "does the signer's policy read
106/// exactly like this one": a policy spelling an e-mail address
107/// `Alice@Example.COM` verifies against a key issued for
108/// `alice@example.com`. A caller that keys on the raw attribute value has to
109/// canonicalize it itself; the proof does not pin spelling.
110///
111/// # Arguments
112///
113/// * `vk`        - The IBS verifying key (master public key).
114/// * `pol`       - The policy whose identity the signer is expected to hold a key for.
115/// * `context`   - The same context the signature was requested under.
116/// * `challenge` - The bytes this verifier chose.
117/// * `sig`       - The signature to check.
118pub fn verify_challenge(
119    vk: &VerifyingKey,
120    pol: &Policy,
121    context: &str,
122    challenge: &[u8],
123    sig: &Signature,
124) -> bool {
125    let Ok(id) = pol.derive_ibs() else {
126        return false;
127    };
128
129    Verifier::default()
130        .chain(challenge_message(context, challenge))
131        .verify(&vk.0, sig, &id)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::identity::Attribute;
138    use crate::test::TestSetup;
139    use alloc::vec;
140
141    const CONTEXT: &str = "cryptify/upload";
142    const CHALLENGE: &[u8] = b"a verifier-chosen challenge";
143
144    #[test]
145    fn test_challenge_roundtrip() {
146        let mut rng = rand::thread_rng();
147        let setup = TestSetup::new(&mut rng);
148        let key = &setup.signing_keys[0];
149
150        let sig = sign_challenge(key, CONTEXT, CHALLENGE, &mut rng);
151
152        assert!(verify_challenge(
153            &setup.ibs_pk,
154            &key.policy,
155            CONTEXT,
156            CHALLENGE,
157            &sig
158        ));
159    }
160
161    #[test]
162    fn test_challenge_wrong_identity_fails() {
163        let mut rng = rand::thread_rng();
164        let setup = TestSetup::new(&mut rng);
165        let key_a = &setup.signing_keys[0];
166        let pol_b = &setup.signing_keys[1].policy;
167
168        let sig = sign_challenge(key_a, CONTEXT, CHALLENGE, &mut rng);
169
170        assert!(!verify_challenge(
171            &setup.ibs_pk,
172            pol_b,
173            CONTEXT,
174            CHALLENGE,
175            &sig
176        ));
177    }
178
179    #[test]
180    fn test_challenge_wrong_challenge_fails() {
181        let mut rng = rand::thread_rng();
182        let setup = TestSetup::new(&mut rng);
183        let key = &setup.signing_keys[0];
184
185        let sig = sign_challenge(key, CONTEXT, b"challenge X", &mut rng);
186
187        assert!(!verify_challenge(
188            &setup.ibs_pk,
189            &key.policy,
190            CONTEXT,
191            b"challenge Y",
192            &sig
193        ));
194    }
195
196    #[test]
197    fn test_challenge_wrong_context_fails() {
198        let mut rng = rand::thread_rng();
199        let setup = TestSetup::new(&mut rng);
200        let key = &setup.signing_keys[0];
201
202        let sig = sign_challenge(key, "a", CHALLENGE, &mut rng);
203
204        assert!(!verify_challenge(
205            &setup.ibs_pk,
206            &key.policy,
207            "b",
208            CHALLENGE,
209            &sig
210        ));
211    }
212
213    /// Length prefixes are what make the signed message unambiguous. Without
214    /// them `("ab", "c")` and `("a", "bc")` concatenate to the same bytes, and
215    /// a proof handed over for one context replays into the other.
216    #[test]
217    fn test_challenge_split_is_unambiguous() {
218        let mut rng = rand::thread_rng();
219        let setup = TestSetup::new(&mut rng);
220        let key = &setup.signing_keys[0];
221
222        let sig = sign_challenge(key, "ab", b"c", &mut rng);
223
224        assert!(!verify_challenge(
225            &setup.ibs_pk,
226            &key.policy,
227            "a",
228            b"bc",
229            &sig
230        ));
231
232        assert_ne!(challenge_message("ab", b"c"), challenge_message("a", b"bc"));
233    }
234
235    /// A signature over the raw challenge, as the header path would make it,
236    /// is not a challenge proof: [`CHALLENGE_DOMAIN`] separates the two, which
237    /// is why no caller can pass it in.
238    #[test]
239    fn test_challenge_domain_separates_from_undomained_signature() {
240        let mut rng = rand::thread_rng();
241        let setup = TestSetup::new(&mut rng);
242        let key = &setup.signing_keys[0];
243
244        let sig = Signer::new().chain(CHALLENGE).sign(&key.key.0, &mut rng);
245
246        assert!(!verify_challenge(
247            &setup.ibs_pk,
248            &key.policy,
249            CONTEXT,
250            CHALLENGE,
251            &sig
252        ));
253    }
254
255    /// `derive_ibs` canonicalizes, so a policy that spells the e-mail address
256    /// differently derives the same identity and verifies. Asserted rather
257    /// than assumed: a consumer keying on the raw attribute value has to
258    /// canonicalize it itself.
259    #[test]
260    fn test_challenge_verifies_under_non_canonical_policy() {
261        let mut rng = rand::thread_rng();
262        let setup = TestSetup::new(&mut rng);
263
264        // `signing_keys[0]` is issued for `alice@example.com`.
265        let key = &setup.signing_keys[0];
266        let non_canonical = Policy {
267            timestamp: key.policy.timestamp,
268            con: vec![Attribute::new(
269                "pbdf.sidn-pbdf.email.email",
270                Some("Alice@Example.COM"),
271            )],
272        };
273
274        assert_ne!(non_canonical, key.policy);
275
276        let sig = sign_challenge(key, CONTEXT, CHALLENGE, &mut rng);
277
278        assert!(verify_challenge(
279            &setup.ibs_pk,
280            &non_canonical,
281            CONTEXT,
282            CHALLENGE,
283            &sig
284        ));
285    }
286}