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}