Skip to main content

aph_core/crypto/
proof_base.rs

1//! Per-proof canonicalization bases — spec §7.2.1.
2//!
3//! A proof is a signature over *bytes*, and this module is the single place
4//! that decides which bytes. Nothing else in the crate may derive a signing
5//! base: signer and verifier must agree exactly, and two code paths that
6//! agree today drift tomorrow.
7//!
8//! # Each proof covers what precedes it, and nothing after it
9//!
10//! These are W3C Data Integrity proof-chain semantics, and they are forced:
11//! a signer cannot sign bytes that do not exist yet, so a base that included
12//! a later proof would be unconstructible. Concretely (§7.2.1):
13//!
14//! | Role | `proof` member of the working copy |
15//! |---|---|
16//! | Lone notary proof | the proof object alone, its `proofValue` emptied |
17//! | Principal proof of a chain | a **one-element array** holding the principal proof, its `proofValue` emptied — the notary proof is DISCARDED, not blanked |
18//! | Notary countersignature | the two-element array, the principal's `proofValue` COMPLETE and the notary's emptied |
19//!
20//! # Why the principal's base keeps the array form
21//!
22//! `"proof": [{…}]` and `"proof": {…}` canonicalize to different bytes, and
23//! that difference **domain-separates** a principal proof from a lone notary
24//! proof. Were the object form used for a one-proof base, an intermediary
25//! could strip the notary proof from a `PrincipalSigned` envelope and
26//! re-present the remainder as a valid single-proof envelope: the signature
27//! would still verify, and the recipient would read the human's own proof as
28//! a mere notary attestation. With the array form the stripped envelope is a
29//! one-element chain, which §7.1.11 rejects outright.
30//!
31//! This is not a redundant wrapper waiting to be simplified away. Removing
32//! it is a security regression, and `stripping_the_notary_proof_...` in
33//! [`crate::crypto::eddsa_jcs`] fails if anyone tries.
34//!
35//! # Empty, never absent
36//!
37//! In every envelope base the covered field is set to the EMPTY STRING, not
38//! removed: JCS over an object with `proofValue` absent and JCS over the same
39//! object with `proofValue` empty are different byte strings, so signer and
40//! verifier must make the same choice. §7.2.1 settles it as the empty string.
41//! Mandate bases are the documented exception — see [`mandate_signing_base`].
42
43/// Which signature's base is being built (spec §7.2.1).
44#[derive(
45  std::fmt::Debug,
46  std::clone::Clone,
47  std::marker::Copy,
48  std::cmp::PartialEq,
49  std::cmp::Eq,
50)]
51pub enum ProofRole {
52  /// The human principal's own proof — the head of a chain.
53  Principal,
54  /// The notary's proof: a lone proof, or the countersignature of a chain.
55  Notary,
56}
57
58/// Builds the JCS-canonical signing base for one proof of an envelope.
59///
60/// The three bases are laid out in the module docs. Two properties matter to
61/// callers:
62///
63/// - `ProofRole::Principal` requires a two-element chain (§7.1.11). Anything
64///   else — a lone proof, a one-element array, a three-element array — is
65///   [`crate::errors::AphError::ProofChainInvalid`] (`APH_E013`), never a
66///   panic, because the shape is attacker-supplied.
67/// - The principal base carries the principal proof in a ONE-ELEMENT ARRAY.
68///   The array form is normative and load-bearing; see the module docs.
69pub fn signing_base(
70  envelope: &crate::envelope::NotarizationEnvelope,
71  role: ProofRole,
72) -> std::result::Result<std::string::String, crate::errors::AphError> {
73  // Resolve the shape before cloning: a wrong-shaped envelope should cost an
74  // error, not a full deep copy.
75  let proofs = base_proofs(&envelope.proof, role)?;
76  let mut working = envelope.clone();
77  working.proof = proofs;
78  canonicalize(&working)
79}
80
81/// The base a Delegation Mandate signature covers (spec §6.1, §7.2.1).
82///
83/// - `ProofRole::Principal` → the canonical form MINUS **both** signature
84///   fields. This is the human's actual grant of authority.
85/// - `ProofRole::Notary` → the canonical form MINUS `notarySignature` only,
86///   with `principalSignature` present and complete. The notary countersigns
87///   what the principal signed.
88///
89/// **The mandate bases REMOVE their members; the envelope bases EMPTY
90/// theirs.** That asymmetry is deliberate, not an oversight: §6.1 and §7.2.1
91/// both say a mandate signature covers the form *minus* the field, and the
92/// only prior statement of this contract in the tree — the doc comment on
93/// [`crate::delegation_mandate::DelegationMandate::notary_signature`] — says
94/// "MINUS the `notary_signature` field" as well. No deployed code computed
95/// this base before, so no field signature is invalidated by honoring the
96/// normative wording here.
97pub fn mandate_signing_base(
98  mandate: &crate::delegation_mandate::DelegationMandate,
99  role: ProofRole,
100) -> std::result::Result<std::string::String, crate::errors::AphError> {
101  let mut value = match serde_json::to_value(mandate) {
102    std::result::Result::Ok(v) => v,
103    // A mandate that cannot be serialized cannot be signed or checked.
104    std::result::Result::Err(_) => {
105      return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
106    }
107  };
108  match value.as_object_mut() {
109    std::option::Option::Some(object) => {
110      object.remove("notarySignature");
111      if role == ProofRole::Principal {
112        object.remove("principalSignature");
113      }
114    }
115    // A struct always serializes to a JSON object; refuse rather than panic
116    // if that ever stops being true.
117    std::option::Option::None => {
118      return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
119    }
120  }
121  std::result::Result::Ok(super::jcs::canonicalize_rfc8785(&value))
122}
123
124/// Mutable access to the proof a given role owns, so a signer can fill in
125/// its `proofValue` after the base is built.
126///
127/// Shares one shape check with [`signing_base`]: the signer and the base
128/// builder must never disagree about which proof is being signed.
129pub(crate) fn proof_mut(
130  envelope: &mut crate::envelope::NotarizationEnvelope,
131  role: ProofRole,
132) -> std::result::Result<&mut crate::envelope::EnvelopeProof, crate::errors::AphError> {
133  // Read the shape while the borrow is still shared; both values are Copy,
134  // so the borrow ends here and the mutable borrow below is legal.
135  let count = envelope.proof.all().len();
136  let is_chain = envelope.proof.is_chain();
137  match &mut envelope.proof {
138    crate::envelope::EnvelopeProofs::Single(proof) => match role {
139      ProofRole::Notary => std::result::Result::Ok(proof),
140      // A lone proof is a notary proof (§7.1.11); there is no principal
141      // proof to fill in.
142      ProofRole::Principal => {
143        std::result::Result::Err(wrong_shape(role, is_chain, count))
144      }
145    },
146    crate::envelope::EnvelopeProofs::Chain(proofs) => {
147      if proofs.len() != CHAIN_LENGTH {
148        return std::result::Result::Err(wrong_shape(role, is_chain, count));
149      }
150      let index = match role {
151        ProofRole::Principal => 0,
152        ProofRole::Notary => 1,
153      };
154      match proofs.get_mut(index) {
155        std::option::Option::Some(proof) => std::result::Result::Ok(proof),
156        // Unreachable after the length check; an error rather than an index
157        // panic keeps a future edit from turning a bug into a crash.
158        std::option::Option::None => {
159          std::result::Result::Err(wrong_shape(role, is_chain, count))
160        }
161      }
162    }
163  }
164}
165
166/// The only chain length §7.1.11 permits: principal proof, notary proof.
167const CHAIN_LENGTH: usize = 2;
168
169/// Builds the `proof` member of the working copy for `role` (§7.2.1).
170fn base_proofs(
171  proofs: &crate::envelope::EnvelopeProofs,
172  role: ProofRole,
173) -> std::result::Result<crate::envelope::EnvelopeProofs, crate::errors::AphError> {
174  let count = proofs.all().len();
175  let is_chain = proofs.is_chain();
176  match role {
177    ProofRole::Principal => {
178      let principal = match proofs.principal() {
179        std::option::Option::Some(proof) => proof,
180        std::option::Option::None => {
181          return std::result::Result::Err(wrong_shape(role, is_chain, count));
182        }
183      };
184      let mut head = principal.clone();
185      head.proof_value = std::string::String::new();
186      // A ONE-ELEMENT ARRAY, not an object: see the module docs. The notary
187      // proof is discarded because it did not exist when the principal
188      // signed.
189      std::result::Result::Ok(crate::envelope::EnvelopeProofs::Chain(std::vec![head]))
190    }
191    ProofRole::Notary => match proofs {
192      crate::envelope::EnvelopeProofs::Single(proof) => {
193        let mut lone = proof.clone();
194        lone.proof_value = std::string::String::new();
195        std::result::Result::Ok(crate::envelope::EnvelopeProofs::Single(lone))
196      }
197      crate::envelope::EnvelopeProofs::Chain(_) => {
198        let principal = match proofs.principal() {
199          std::option::Option::Some(proof) => proof,
200          std::option::Option::None => {
201            return std::result::Result::Err(wrong_shape(role, is_chain, count));
202          }
203        };
204        let notary = match proofs.notary() {
205          std::option::Option::Some(proof) => proof,
206          std::option::Option::None => {
207            return std::result::Result::Err(wrong_shape(role, is_chain, count));
208          }
209        };
210        let mut tail = notary.clone();
211        tail.proof_value = std::string::String::new();
212        // The principal's proofValue stays COMPLETE: that is what makes the
213        // countersignature cover the principal's signature rather than an
214        // empty placeholder.
215        std::result::Result::Ok(crate::envelope::EnvelopeProofs::Chain(std::vec![
216          principal.clone(),
217          tail,
218        ]))
219      }
220    },
221  }
222}
223
224/// JCS-canonicalizes an envelope working copy.
225fn canonicalize(
226  envelope: &crate::envelope::NotarizationEnvelope,
227) -> std::result::Result<std::string::String, crate::errors::AphError> {
228  match serde_json::to_value(envelope) {
229    std::result::Result::Ok(value) => {
230      std::result::Result::Ok(super::jcs::canonicalize_rfc8785(&value))
231    }
232    // An envelope that cannot be serialized cannot be signed or checked.
233    std::result::Result::Err(_) => {
234      std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature)
235    }
236  }
237}
238
239/// Builds the `APH_E013` an envelope of the wrong proof shape earns, naming
240/// what was found so an operator can fix the producer.
241fn wrong_shape(role: ProofRole, is_chain: bool, count: usize) -> crate::errors::AphError {
242  let form = if is_chain { "array" } else { "object" };
243  let expected = match role {
244    ProofRole::Principal => "a two-element proof chain",
245    ProofRole::Notary => "a single proof or a two-element proof chain",
246  };
247  crate::errors::AphError::proof_chain_invalid(std::format!(
248    "the {} canonicalization base (§7.2.1) requires {}; this envelope carries {} proof(s) in {} form",
249    role_label(role),
250    expected,
251    count,
252    form
253  ))
254}
255
256/// Names a role for error messages.
257fn role_label(role: ProofRole) -> &'static str {
258  match role {
259    ProofRole::Principal => "principal",
260    ProofRole::Notary => "notary",
261  }
262}
263
264/// Fixtures shared by this module's tests and [`crate::crypto::eddsa_jcs`]'s.
265///
266/// Both modules must exercise the SAME chain shape: a base built here and a
267/// signature produced there that disagreed about the fixture would hide the
268/// very mismatch these tests exist to catch.
269#[cfg(test)]
270pub(crate) mod test_support {
271  /// The golden single-proof (lone notary) envelope every crypto test starts
272  /// from.
273  pub(crate) fn single_proof_envelope() -> crate::envelope::NotarizationEnvelope {
274    let raw = include_str!("../../tests/golden/slack_reply_envelope.json");
275    serde_json::from_str(raw).expect("golden fixture parses")
276  }
277
278  /// `urn:uuid` of the principal proof in [`chain_envelope`].
279  pub(crate) const PRINCIPAL_PROOF_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000a1";
280  /// `urn:uuid` of the notary proof in [`chain_envelope`].
281  pub(crate) const NOTARY_PROOF_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000a2";
282
283  /// A `PrincipalSigned` envelope whose two-element chain is structurally
284  /// complete but UNSIGNED: both `proofValue`s are empty, ready for
285  /// `sign_as_principal` then `countersign_as_notary` in that order (§7.2.1).
286  ///
287  /// The `verificationMethod`s are the fixture's own DIDs. Binding the
288  /// principal proof's method to `credentialSubject.humanPrincipal.id` is the
289  /// verifier's duty (§8.3.1 step 1c), not this layer's — the crypto layer is
290  /// handed the key it must check against.
291  pub(crate) fn chain_envelope() -> crate::envelope::NotarizationEnvelope {
292    let mut envelope = single_proof_envelope();
293    let template = envelope
294      .proof
295      .notary()
296      .expect("the golden fixture carries a lone notary proof")
297      .clone();
298
299    let mut principal = template.clone();
300    principal.id = std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID));
301    principal.previous_proof = std::option::Option::None;
302    principal.proof_purpose = std::string::String::from("assertionMethod");
303    principal.verification_method = std::format!(
304      "{did}#{fragment}",
305      did = envelope.credential_subject.human_principal.id,
306      fragment = envelope
307        .credential_subject
308        .human_principal
309        .id
310        .trim_start_matches("did:key:")
311    );
312    principal.proof_value = std::string::String::new();
313
314    let mut notary = template;
315    notary.id = std::option::Option::Some(std::string::String::from(NOTARY_PROOF_ID));
316    notary.previous_proof =
317      std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID));
318    notary.proof_purpose = std::string::String::from("authentication");
319    notary.proof_value = std::string::String::new();
320
321    envelope.credential_subject.policy.attestation_mode =
322      std::option::Option::Some(crate::envelope::AttestationMode::PrincipalSigned);
323    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![principal, notary]);
324    envelope
325  }
326}
327
328#[cfg(test)]
329mod tests {
330  fn single() -> crate::envelope::NotarizationEnvelope {
331    super::test_support::single_proof_envelope()
332  }
333
334  fn chain() -> crate::envelope::NotarizationEnvelope {
335    super::test_support::chain_envelope()
336  }
337
338  fn sample_mandate() -> crate::delegation_mandate::DelegationMandate {
339    crate::delegation_mandate::DelegationMandate {
340      id: std::string::String::from("urn:uuid:00000000-0000-4000-8000-000000000001"),
341      human_principal_did: std::string::String::from("did:key:zHuman"),
342      agent_did: std::string::String::from("did:web:agent.squillo.com"),
343      allowed_channels: std::vec![std::string::String::from("slack")],
344      rate_limit_per_hour: std::option::Option::Some(30),
345      valid_from: std::string::String::from("2026-05-21T00:00:00Z"),
346      valid_until: std::string::String::from("2026-06-21T00:00:00Z"),
347      principal_signature: std::string::String::from("zPrincipalSignatureBytes"),
348      notary_signature: std::string::String::from("zNotarySignatureBytes"),
349    }
350  }
351
352  #[test]
353  fn the_three_bases_are_pairwise_distinct() {
354    // Domain separation is the whole point of §7.2.1. If any two bases ever
355    // canonicalized identically, a signature made for one role would verify
356    // in another and a proof would be transplantable between positions —
357    // e.g. a lone notary proof re-presented as a human's authorization.
358    let lone = super::signing_base(&single(), super::ProofRole::Notary)
359      .expect("a lone notary base is always constructible");
360    let chained = chain();
361    let principal = super::signing_base(&chained, super::ProofRole::Principal)
362      .expect("a two-element chain has a principal base");
363    let notary = super::signing_base(&chained, super::ProofRole::Notary)
364      .expect("a two-element chain has a notary base");
365
366    std::assert_ne!(lone, principal);
367    std::assert_ne!(lone, notary);
368    std::assert_ne!(principal, notary);
369  }
370
371  #[test]
372  fn principal_base_is_a_one_element_array_not_an_object() {
373    // The array form is normative (§7.2.1) and is what stops an intermediary
374    // stripping the notary proof and re-presenting the remainder as a valid
375    // single-proof envelope. An implementation that "simplified" this to the
376    // object form would still round-trip its own signatures, so only a byte
377    // assertion catches it.
378    let base = super::signing_base(&chain(), super::ProofRole::Principal)
379      .expect("a two-element chain has a principal base");
380    std::assert!(
381      base.contains(r#""proof":[{"#),
382      "principal base must carry a one-element ARRAY: {}",
383      base
384    );
385  }
386
387  #[test]
388  fn principal_base_discards_the_notary_proof_entirely() {
389    // "Discarded, not blanked" (§7.2.1): the notary proof did not exist when
390    // the principal signed, so no trace of it — not even an emptied object —
391    // may appear in the principal's base.
392    let base = super::signing_base(&chain(), super::ProofRole::Principal)
393      .expect("a two-element chain has a principal base");
394    std::assert!(
395      !base.contains(super::test_support::NOTARY_PROOF_ID),
396      "principal base must not mention the notary proof: {}",
397      base
398    );
399  }
400
401  #[test]
402  fn notary_base_keeps_both_proofs() {
403    // The countersignature must cover the principal proof (§7.1.11) — that
404    // is what stops a notary detaching a principal's signature and
405    // re-attaching it to a different envelope.
406    let base = super::signing_base(&chain(), super::ProofRole::Notary)
407      .expect("a two-element chain has a notary base");
408    std::assert!(
409      base.contains(super::test_support::PRINCIPAL_PROOF_ID)
410        && base.contains(super::test_support::NOTARY_PROOF_ID),
411      "notary base must carry both proofs: {}",
412      base
413    );
414  }
415
416  #[test]
417  fn every_envelope_base_empties_rather_than_removes_proof_value() {
418    // JCS over an object with `proofValue` absent and JCS over the same
419    // object with it empty are different bytes, so signer and verifier must
420    // make the same choice. §7.2.1 fixes it as the empty string, for all
421    // three bases — not just the lone-proof one the earlier code covered.
422    let chained = chain();
423    for (label, base) in [
424      (
425        "lone",
426        super::signing_base(&single(), super::ProofRole::Notary).expect("lone base"),
427      ),
428      (
429        "principal",
430        super::signing_base(&chained, super::ProofRole::Principal).expect("principal base"),
431      ),
432      (
433        "notary",
434        super::signing_base(&chained, super::ProofRole::Notary).expect("notary base"),
435      ),
436    ] {
437      std::assert!(
438        base.contains(r#""proofValue":"""#),
439        "{} base must keep proofValue present-but-empty: {}",
440        label,
441        base
442      );
443    }
444  }
445
446  #[test]
447  fn principal_base_of_a_single_proof_envelope_is_aph_e013() {
448    // A lone proof is a notary proof: there is no principal base to build.
449    // The proof shape is attacker-supplied, so this must be a typed refusal
450    // a verifier can report, never an index panic in a parser-adjacent path.
451    let error = super::signing_base(&single(), super::ProofRole::Principal)
452      .expect_err("a single proof has no principal base");
453    std::assert_eq!(error.code(), "APH_E013");
454  }
455
456  #[test]
457  fn principal_base_of_a_one_element_chain_is_aph_e013() {
458    // The exact artifact §7.2.1 describes an attacker producing: a
459    // `PrincipalSigned` envelope with the notary proof stripped. It must be
460    // rejected as a malformed chain rather than treated as signable.
461    let mut envelope = chain();
462    let principal = envelope
463      .proof
464      .principal()
465      .expect("the chain fixture has a principal proof")
466      .clone();
467    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![principal]);
468    let error = super::signing_base(&envelope, super::ProofRole::Principal)
469      .expect_err("a one-element chain is not a chain");
470    std::assert_eq!(error.code(), "APH_E013");
471  }
472
473  #[test]
474  fn notary_base_of_a_three_element_chain_is_aph_e013() {
475    // §7.1.11 fixes the chain at exactly two proofs. A longer chain has no
476    // defined base, and guessing (say, "sign the last one") would let a
477    // producer smuggle an extra proof under a real countersignature.
478    let mut envelope = chain();
479    let mut proofs = envelope.proof.all().to_vec();
480    let extra = proofs
481      .last()
482      .expect("the chain fixture is non-empty")
483      .clone();
484    proofs.push(extra);
485    envelope.proof = crate::envelope::EnvelopeProofs::Chain(proofs);
486    let error = super::signing_base(&envelope, super::ProofRole::Notary)
487      .expect_err("a three-element chain has no defined base");
488    std::assert_eq!(error.code(), "APH_E013");
489  }
490
491  #[test]
492  fn mandate_principal_base_removes_both_signature_fields() {
493    // §6.1: `principalSignature` covers the form MINUS both signatures. If
494    // either leaked into the base, the human could not sign the mandate
495    // before the notary countersigned it — the required order.
496    let base = super::mandate_signing_base(&sample_mandate(), super::ProofRole::Principal)
497      .expect("a mandate always has a principal base");
498    std::assert!(
499      !base.contains("principalSignature") && !base.contains("notarySignature"),
500      "principal mandate base must carry neither signature: {}",
501      base
502    );
503  }
504
505  #[test]
506  fn mandate_notary_base_keeps_the_principal_signature() {
507    // "The notary countersigns what the principal signed" (§6.1). The
508    // principal's signature must be inside the notary's covered bytes, or a
509    // notary signature could be moved onto a mandate the human never signed.
510    let base = super::mandate_signing_base(&sample_mandate(), super::ProofRole::Notary)
511      .expect("a mandate always has a notary base");
512    std::assert!(
513      base.contains("zPrincipalSignatureBytes") && !base.contains("notarySignature"),
514      "notary mandate base must keep principalSignature and drop its own: {}",
515      base
516    );
517  }
518
519  #[test]
520  fn the_two_mandate_bases_are_distinct() {
521    // Same domain-separation argument as the envelope bases: if the human's
522    // base equalled the notary's, one party's signature would verify as the
523    // other's and the countersignature would prove nothing.
524    let mandate = sample_mandate();
525    let principal = super::mandate_signing_base(&mandate, super::ProofRole::Principal)
526      .expect("principal mandate base");
527    let notary =
528      super::mandate_signing_base(&mandate, super::ProofRole::Notary).expect("notary mandate base");
529    std::assert_ne!(principal, notary);
530  }
531
532  #[test]
533  fn bases_are_deterministic() {
534    // Every signature in the protocol is over these bytes, so the same input
535    // must canonicalize identically on every call. A base that depended on
536    // map iteration order would verify locally and fail across machines.
537    let chained = chain();
538    let once = super::signing_base(&chained, super::ProofRole::Notary).expect("notary base");
539    let twice = super::signing_base(&chained, super::ProofRole::Notary).expect("notary base");
540    std::assert_eq!(once, twice);
541  }
542}