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/// `proof.type` of the Data Integrity carriage — the value both
44/// JCS cryptosuites (`eddsa-jcs-2022`, `ecdsa-jcs-2019`) declare (§7.1.11).
45///
46/// The other carriage is `JsonWebSignature2020`; see
47/// [`crate::crypto::jws_envelope::PROOF_TYPE`]. Naming both makes the choice
48/// between them a match on constants rather than on string literals scattered
49/// across three modules.
50pub const DATA_INTEGRITY_PROOF_TYPE: &str = "DataIntegrityProof";
51
52/// Which signature's base is being built (spec §7.2.1).
53#[derive(
54  std::fmt::Debug,
55  std::clone::Clone,
56  std::marker::Copy,
57  std::cmp::PartialEq,
58  std::cmp::Eq,
59)]
60pub enum ProofRole {
61  /// The human principal's own proof — the head of a chain.
62  Principal,
63  /// The notary's proof: a lone proof, or the countersignature of a chain.
64  Notary,
65}
66
67/// Builds the JCS-canonical signing base for one proof of an envelope.
68///
69/// The three bases are laid out in the module docs. Two properties matter to
70/// callers:
71///
72/// - `ProofRole::Principal` requires a two-element chain (§7.1.11). Anything
73///   else — a lone proof, a one-element array, a three-element array — is
74///   [`crate::errors::AphError::ProofChainInvalid`] (`APH_E013`), never a
75///   panic, because the shape is attacker-supplied.
76/// - The principal base carries the principal proof in a ONE-ELEMENT ARRAY.
77///   The array form is normative and load-bearing; see the module docs.
78pub fn signing_base(
79  envelope: &crate::envelope::NotarizationEnvelope,
80  role: ProofRole,
81) -> std::result::Result<std::string::String, crate::errors::AphError> {
82  // Resolve the shape before cloning: a wrong-shaped envelope should cost an
83  // error, not a full deep copy.
84  let proofs = base_proofs(&envelope.proof, role)?;
85  let mut working = envelope.clone();
86  working.proof = proofs;
87  canonicalize(&working)
88}
89
90/// The base a Delegation Mandate signature covers (spec §6.1, §7.2.1).
91///
92/// - `ProofRole::Principal` → the canonical form MINUS **both** signature
93///   fields. This is the human's actual grant of authority.
94/// - `ProofRole::Notary` → the canonical form MINUS `notarySignature` only,
95///   with `principalSignature` present and complete. The notary countersigns
96///   what the principal signed.
97///
98/// **The mandate bases REMOVE their members; the envelope bases EMPTY
99/// theirs.** That asymmetry is deliberate, not an oversight: §6.1 and §7.2.1
100/// both say a mandate signature covers the form *minus* the field, and the
101/// only prior statement of this contract in the tree — the doc comment on
102/// [`crate::delegation_mandate::DelegationMandate::notary_signature`] — says
103/// "MINUS the `notary_signature` field" as well. No deployed code computed
104/// this base before, so no field signature is invalidated by honoring the
105/// normative wording here.
106pub fn mandate_signing_base(
107  mandate: &crate::delegation_mandate::DelegationMandate,
108  role: ProofRole,
109) -> std::result::Result<std::string::String, crate::errors::AphError> {
110  let mut value = match serde_json::to_value(mandate) {
111    std::result::Result::Ok(v) => v,
112    // A mandate that cannot be serialized cannot be signed or checked.
113    std::result::Result::Err(_) => {
114      return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
115    }
116  };
117  match value.as_object_mut() {
118    std::option::Option::Some(object) => {
119      object.remove("notarySignature");
120      if role == ProofRole::Principal {
121        object.remove("principalSignature");
122      }
123    }
124    // A struct always serializes to a JSON object; refuse rather than panic
125    // if that ever stops being true.
126    std::option::Option::None => {
127      return std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature);
128    }
129  }
130  std::result::Result::Ok(super::jcs::canonicalize_rfc8785(&value))
131}
132
133/// Mutable access to the proof a given role owns, so a signer can fill in
134/// its `proofValue` after the base is built.
135///
136/// Shares one shape check with [`signing_base`]: the signer and the base
137/// builder must never disagree about which proof is being signed.
138pub(crate) fn proof_mut(
139  envelope: &mut crate::envelope::NotarizationEnvelope,
140  role: ProofRole,
141) -> std::result::Result<&mut crate::envelope::EnvelopeProof, crate::errors::AphError> {
142  // Read the shape while the borrow is still shared; both values are Copy,
143  // so the borrow ends here and the mutable borrow below is legal.
144  let count = envelope.proof.all().len();
145  let is_chain = envelope.proof.is_chain();
146  match &mut envelope.proof {
147    crate::envelope::EnvelopeProofs::Single(proof) => match role {
148      ProofRole::Notary => std::result::Result::Ok(proof),
149      // A lone proof is a notary proof (§7.1.11); there is no principal
150      // proof to fill in.
151      ProofRole::Principal => {
152        std::result::Result::Err(wrong_shape(role, is_chain, count))
153      }
154    },
155    crate::envelope::EnvelopeProofs::Chain(proofs) => {
156      if proofs.len() != CHAIN_LENGTH {
157        return std::result::Result::Err(wrong_shape(role, is_chain, count));
158      }
159      let index = match role {
160        ProofRole::Principal => 0,
161        ProofRole::Notary => 1,
162      };
163      match proofs.get_mut(index) {
164        std::option::Option::Some(proof) => std::result::Result::Ok(proof),
165        // Unreachable after the length check; an error rather than an index
166        // panic keeps a future edit from turning a bug into a crash.
167        std::option::Option::None => {
168          std::result::Result::Err(wrong_shape(role, is_chain, count))
169        }
170      }
171    }
172  }
173}
174
175/// Refuses to countersign unless the envelope is a two-element chain whose
176/// principal proof already carries a signature (§7.1.11).
177///
178/// This is the whole point of a countersignature: signing a chain whose
179/// principal proof is an empty placeholder would attest to nothing while
180/// producing an artifact that looks exactly like a valid `PrincipalSigned`
181/// envelope. Shared by every cryptosuite's `countersign_as_notary`, because
182/// the rule is about the proof CHAIN and not about the curve — two suites
183/// each carrying their own copy is how the two come to disagree about what a
184/// notary may sign.
185pub(crate) fn require_signed_principal(
186  envelope: &crate::envelope::NotarizationEnvelope,
187) -> std::result::Result<(), crate::errors::AphError> {
188  let principal_is_signed = match envelope.proof.principal() {
189    std::option::Option::Some(proof) => !proof.proof_value.is_empty(),
190    std::option::Option::None => {
191      return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
192        "a notary countersignature requires a two-element proof chain (§7.1.11)",
193      ));
194    }
195  };
196  if !principal_is_signed {
197    return std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
198      "the principal proof carries no proofValue; a countersignature over an unsigned principal proof attests nothing (§7.1.11)",
199    ));
200  }
201  std::result::Result::Ok(())
202}
203
204/// The error a failed proof earns, by role (§11).
205///
206/// `APH_E011` and `APH_E001` are different codes on purpose: only the first
207/// means the authorization itself is forged, and an operator reading
208/// `APH_E001` would go looking at notary configuration instead. Shared by
209/// every cryptosuite and both proof carriages — the code a verifier reports
210/// must not depend on which algorithm happened to fail.
211pub(crate) fn signature_failure(role: ProofRole) -> crate::errors::AphError {
212  match role {
213    ProofRole::Principal => crate::errors::AphError::PrincipalSignatureInvalid,
214    ProofRole::Notary => crate::errors::AphError::InvalidEnvelopeSignature,
215  }
216}
217
218/// The proof a role owns, or `APH_E013` when the envelope carries none there.
219///
220/// The read-only twin of [`proof_mut`], shared by every verifier so that
221/// "there is no proof in that position" is a chain problem with one message
222/// rather than a per-suite paraphrase.
223pub(crate) fn proof_of(
224  envelope: &crate::envelope::NotarizationEnvelope,
225  role: ProofRole,
226) -> std::result::Result<&crate::envelope::EnvelopeProof, crate::errors::AphError> {
227  let proof = match role {
228    ProofRole::Principal => envelope.proof.principal(),
229    ProofRole::Notary => envelope.proof.notary(),
230  };
231  match proof {
232    std::option::Option::Some(proof) => std::result::Result::Ok(proof),
233    // No proof in that position: a chain problem, not a bad signature.
234    std::option::Option::None => {
235      std::result::Result::Err(crate::errors::AphError::proof_chain_invalid(
236        "the envelope carries no proof in the requested chain position (§7.1.11)",
237      ))
238    }
239  }
240}
241
242/// The only chain length §7.1.11 permits: principal proof, notary proof.
243const CHAIN_LENGTH: usize = 2;
244
245/// Builds the `proof` member of the working copy for `role` (§7.2.1).
246fn base_proofs(
247  proofs: &crate::envelope::EnvelopeProofs,
248  role: ProofRole,
249) -> std::result::Result<crate::envelope::EnvelopeProofs, crate::errors::AphError> {
250  let count = proofs.all().len();
251  let is_chain = proofs.is_chain();
252  match role {
253    ProofRole::Principal => {
254      let principal = match proofs.principal() {
255        std::option::Option::Some(proof) => proof,
256        std::option::Option::None => {
257          return std::result::Result::Err(wrong_shape(role, is_chain, count));
258        }
259      };
260      let mut head = principal.clone();
261      head.proof_value = std::string::String::new();
262      // A ONE-ELEMENT ARRAY, not an object: see the module docs. The notary
263      // proof is discarded because it did not exist when the principal
264      // signed.
265      std::result::Result::Ok(crate::envelope::EnvelopeProofs::Chain(std::vec![head]))
266    }
267    ProofRole::Notary => match proofs {
268      crate::envelope::EnvelopeProofs::Single(proof) => {
269        let mut lone = proof.clone();
270        lone.proof_value = std::string::String::new();
271        std::result::Result::Ok(crate::envelope::EnvelopeProofs::Single(lone))
272      }
273      crate::envelope::EnvelopeProofs::Chain(_) => {
274        let principal = match proofs.principal() {
275          std::option::Option::Some(proof) => proof,
276          std::option::Option::None => {
277            return std::result::Result::Err(wrong_shape(role, is_chain, count));
278          }
279        };
280        let notary = match proofs.notary() {
281          std::option::Option::Some(proof) => proof,
282          std::option::Option::None => {
283            return std::result::Result::Err(wrong_shape(role, is_chain, count));
284          }
285        };
286        let mut tail = notary.clone();
287        tail.proof_value = std::string::String::new();
288        // The principal's proofValue stays COMPLETE: that is what makes the
289        // countersignature cover the principal's signature rather than an
290        // empty placeholder.
291        std::result::Result::Ok(crate::envelope::EnvelopeProofs::Chain(std::vec![
292          principal.clone(),
293          tail,
294        ]))
295      }
296    },
297  }
298}
299
300/// JCS-canonicalizes an envelope working copy.
301fn canonicalize(
302  envelope: &crate::envelope::NotarizationEnvelope,
303) -> std::result::Result<std::string::String, crate::errors::AphError> {
304  match serde_json::to_value(envelope) {
305    std::result::Result::Ok(value) => {
306      std::result::Result::Ok(super::jcs::canonicalize_rfc8785(&value))
307    }
308    // An envelope that cannot be serialized cannot be signed or checked.
309    std::result::Result::Err(_) => {
310      std::result::Result::Err(crate::errors::AphError::InvalidEnvelopeSignature)
311    }
312  }
313}
314
315/// Builds the `APH_E013` an envelope of the wrong proof shape earns, naming
316/// what was found so an operator can fix the producer.
317fn wrong_shape(role: ProofRole, is_chain: bool, count: usize) -> crate::errors::AphError {
318  let form = if is_chain { "array" } else { "object" };
319  let expected = match role {
320    ProofRole::Principal => "a two-element proof chain",
321    ProofRole::Notary => "a single proof or a two-element proof chain",
322  };
323  crate::errors::AphError::proof_chain_invalid(std::format!(
324    "the {} canonicalization base (§7.2.1) requires {}; this envelope carries {} proof(s) in {} form",
325    role_label(role),
326    expected,
327    count,
328    form
329  ))
330}
331
332/// Names a role for error messages.
333fn role_label(role: ProofRole) -> &'static str {
334  match role {
335    ProofRole::Principal => "principal",
336    ProofRole::Notary => "notary",
337  }
338}
339
340/// Fixtures shared by this module's tests and those of the three suites
341/// ([`crate::crypto::eddsa_jcs`], [`crate::crypto::ecdsa_jcs`],
342/// [`crate::crypto::jws_envelope`]).
343///
344/// Every module must exercise the SAME chain shape and the SAME identities: a
345/// base built here and a signature produced there that disagreed about the
346/// fixture would hide the very mismatch these tests exist to catch, and four
347/// modules each carrying their own copy of a P-256 scalar is how "the
348/// principal's key" comes to mean two different keys in one repository.
349#[cfg(test)]
350pub(crate) mod test_support {
351  /// The PRINCIPAL's published P-256 test key: the RFC 6979 Appendix A.2.5
352  /// sample private key for curve P-256 with SHA-256.
353  ///
354  /// ⛔ A P-256 scalar cannot be a repeated-byte fake the way an Ed25519 seed
355  /// can — it must lie in `1..n` — which is why these constants cite a
356  /// document instead of using `[9u8; 32]`. Both authorize nothing, and anyone
357  /// can re-derive every byte from the RFC that publishes them.
358  /// `crate::crypto::ecdsa_jcs`'s
359  /// `the_shared_p256_scalars_reproduce_their_published_documents` is the
360  /// tripwire that proves each really is the vector it claims.
361  ///
362  /// The same role split the published vectors use:
363  /// `examples/es256_signed_envelope.json` signs its principal proof with this
364  /// key and its countersignature with [`NOTARY_P256_SCALAR`].
365  pub(crate) const PRINCIPAL_P256_SCALAR: [u8; 32] = [
366    0xc9, 0xaf, 0xa9, 0xd8, 0x45, 0xba, 0x75, 0x16, 0x6b, 0x5c, 0x21, 0x57, 0x67, 0xb1, 0xd6, 0x93,
367    0x4e, 0x50, 0xc3, 0xdb, 0x36, 0xe8, 0x9b, 0x12, 0x7b, 0x8a, 0x62, 0x2b, 0x12, 0x0f, 0x67, 0x21,
368  ];
369
370  /// The NOTARY's published P-256 test key: the `d` value of the ES256 example
371  /// JWK in RFC 7515 Appendix A.3.1.
372  ///
373  /// A DIFFERENT published vector from [`PRINCIPAL_P256_SCALAR`] on purpose —
374  /// a suite test that used one key for both roles could not tell a
375  /// countersignature from an authorization.
376  pub(crate) const NOTARY_P256_SCALAR: [u8; 32] = [
377    0x8e, 0x9b, 0x10, 0x9e, 0x71, 0x90, 0x98, 0xbf, 0x98, 0x04, 0x87, 0xdf, 0x1f, 0x5d, 0x77, 0xe9,
378    0xcb, 0x29, 0x60, 0x6e, 0xbe, 0xd2, 0x26, 0x3b, 0x5f, 0x57, 0xc2, 0x13, 0xdf, 0x84, 0xf4, 0xb2,
379  ];
380
381  /// Builds a P-256 signing key from one of the published scalars above.
382  pub(crate) fn p256_key(scalar: &[u8; 32]) -> p256::ecdsa::SigningKey {
383    p256::ecdsa::SigningKey::from_slice(scalar).expect("a published P-256 test scalar is in 1..n")
384  }
385
386  /// The golden single-proof (lone notary) envelope every crypto test starts
387  /// from.
388  pub(crate) fn single_proof_envelope() -> crate::envelope::NotarizationEnvelope {
389    let raw = include_str!("../../tests/golden/slack_reply_envelope.json");
390    serde_json::from_str(raw).expect("golden fixture parses")
391  }
392
393  /// `urn:uuid` of the principal proof in [`chain_envelope`].
394  pub(crate) const PRINCIPAL_PROOF_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000a1";
395  /// `urn:uuid` of the notary proof in [`chain_envelope`].
396  pub(crate) const NOTARY_PROOF_ID: &str = "urn:uuid:00000000-0000-4000-8000-0000000000a2";
397
398  /// A `PrincipalSigned` envelope whose two-element chain is structurally
399  /// complete but UNSIGNED: both `proofValue`s are empty, ready for
400  /// `sign_as_principal` then `countersign_as_notary` in that order (§7.2.1).
401  ///
402  /// The `verificationMethod`s are the fixture's own DIDs. Binding the
403  /// principal proof's method to `credentialSubject.humanPrincipal.id` is the
404  /// verifier's duty (§8.3.1 step 1c), not this layer's — the crypto layer is
405  /// handed the key it must check against.
406  pub(crate) fn chain_envelope() -> crate::envelope::NotarizationEnvelope {
407    let mut envelope = single_proof_envelope();
408    let template = envelope
409      .proof
410      .notary()
411      .expect("the golden fixture carries a lone notary proof")
412      .clone();
413
414    let mut principal = template.clone();
415    principal.id = std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID));
416    principal.previous_proof = std::option::Option::None;
417    principal.proof_purpose = std::string::String::from("assertionMethod");
418    principal.verification_method = std::format!(
419      "{did}#{fragment}",
420      did = envelope.credential_subject.human_principal.id,
421      fragment = envelope
422        .credential_subject
423        .human_principal
424        .id
425        .trim_start_matches("did:key:")
426    );
427    principal.proof_value = std::string::String::new();
428
429    let mut notary = template;
430    notary.id = std::option::Option::Some(std::string::String::from(NOTARY_PROOF_ID));
431    notary.previous_proof =
432      std::option::Option::Some(std::string::String::from(PRINCIPAL_PROOF_ID));
433    notary.proof_purpose = std::string::String::from("authentication");
434    notary.proof_value = std::string::String::new();
435
436    envelope.credential_subject.policy.attestation_mode =
437      std::option::Option::Some(crate::envelope::AttestationMode::PrincipalSigned);
438    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![principal, notary]);
439    envelope
440  }
441}
442
443#[cfg(test)]
444mod tests {
445  fn single() -> crate::envelope::NotarizationEnvelope {
446    super::test_support::single_proof_envelope()
447  }
448
449  fn chain() -> crate::envelope::NotarizationEnvelope {
450    super::test_support::chain_envelope()
451  }
452
453  fn sample_mandate() -> crate::delegation_mandate::DelegationMandate {
454    crate::delegation_mandate::DelegationMandate {
455      id: std::string::String::from("urn:uuid:00000000-0000-4000-8000-000000000001"),
456      human_principal_did: std::string::String::from("did:key:zHuman"),
457      agent_did: std::string::String::from("did:web:agent.squillo.com"),
458      allowed_channels: std::vec![crate::envelope::ChannelKind::Slack],
459      allowed_recipient_classes: std::option::Option::None,
460      rate_limit_per_hour: std::option::Option::Some(30),
461      valid_from: std::string::String::from("2026-05-21T00:00:00Z"),
462      valid_until: std::string::String::from("2026-06-21T00:00:00Z"),
463      principal_signature: std::string::String::from("zPrincipalSignatureBytes"),
464      notary_signature: std::string::String::from("zNotarySignatureBytes"),
465    }
466  }
467
468  #[test]
469  fn the_three_bases_are_pairwise_distinct() {
470    // Domain separation is the whole point of §7.2.1. If any two bases ever
471    // canonicalized identically, a signature made for one role would verify
472    // in another and a proof would be transplantable between positions —
473    // e.g. a lone notary proof re-presented as a human's authorization.
474    let lone = super::signing_base(&single(), super::ProofRole::Notary)
475      .expect("a lone notary base is always constructible");
476    let chained = chain();
477    let principal = super::signing_base(&chained, super::ProofRole::Principal)
478      .expect("a two-element chain has a principal base");
479    let notary = super::signing_base(&chained, super::ProofRole::Notary)
480      .expect("a two-element chain has a notary base");
481
482    std::assert_ne!(lone, principal);
483    std::assert_ne!(lone, notary);
484    std::assert_ne!(principal, notary);
485  }
486
487  #[test]
488  fn principal_base_is_a_one_element_array_not_an_object() {
489    // The array form is normative (§7.2.1) and is what stops an intermediary
490    // stripping the notary proof and re-presenting the remainder as a valid
491    // single-proof envelope. An implementation that "simplified" this to the
492    // object form would still round-trip its own signatures, so only a byte
493    // assertion catches it.
494    let base = super::signing_base(&chain(), super::ProofRole::Principal)
495      .expect("a two-element chain has a principal base");
496    std::assert!(
497      base.contains(r#""proof":[{"#),
498      "principal base must carry a one-element ARRAY: {}",
499      base
500    );
501  }
502
503  #[test]
504  fn principal_base_discards_the_notary_proof_entirely() {
505    // "Discarded, not blanked" (§7.2.1): the notary proof did not exist when
506    // the principal signed, so no trace of it — not even an emptied object —
507    // may appear in the principal's base.
508    let base = super::signing_base(&chain(), super::ProofRole::Principal)
509      .expect("a two-element chain has a principal base");
510    std::assert!(
511      !base.contains(super::test_support::NOTARY_PROOF_ID),
512      "principal base must not mention the notary proof: {}",
513      base
514    );
515  }
516
517  #[test]
518  fn notary_base_keeps_both_proofs() {
519    // The countersignature must cover the principal proof (§7.1.11) — that
520    // is what stops a notary detaching a principal's signature and
521    // re-attaching it to a different envelope.
522    let base = super::signing_base(&chain(), super::ProofRole::Notary)
523      .expect("a two-element chain has a notary base");
524    std::assert!(
525      base.contains(super::test_support::PRINCIPAL_PROOF_ID)
526        && base.contains(super::test_support::NOTARY_PROOF_ID),
527      "notary base must carry both proofs: {}",
528      base
529    );
530  }
531
532  #[test]
533  fn every_envelope_base_empties_rather_than_removes_proof_value() {
534    // JCS over an object with `proofValue` absent and JCS over the same
535    // object with it empty are different bytes, so signer and verifier must
536    // make the same choice. §7.2.1 fixes it as the empty string, for all
537    // three bases — not just the lone-proof one the earlier code covered.
538    let chained = chain();
539    for (label, base) in [
540      (
541        "lone",
542        super::signing_base(&single(), super::ProofRole::Notary).expect("lone base"),
543      ),
544      (
545        "principal",
546        super::signing_base(&chained, super::ProofRole::Principal).expect("principal base"),
547      ),
548      (
549        "notary",
550        super::signing_base(&chained, super::ProofRole::Notary).expect("notary base"),
551      ),
552    ] {
553      std::assert!(
554        base.contains(r#""proofValue":"""#),
555        "{} base must keep proofValue present-but-empty: {}",
556        label,
557        base
558      );
559    }
560  }
561
562  #[test]
563  fn principal_base_of_a_single_proof_envelope_is_aph_e013() {
564    // A lone proof is a notary proof: there is no principal base to build.
565    // The proof shape is attacker-supplied, so this must be a typed refusal
566    // a verifier can report, never an index panic in a parser-adjacent path.
567    let error = super::signing_base(&single(), super::ProofRole::Principal)
568      .expect_err("a single proof has no principal base");
569    std::assert_eq!(error.code(), "APH_E013");
570  }
571
572  #[test]
573  fn principal_base_of_a_one_element_chain_is_aph_e013() {
574    // The exact artifact §7.2.1 describes an attacker producing: a
575    // `PrincipalSigned` envelope with the notary proof stripped. It must be
576    // rejected as a malformed chain rather than treated as signable.
577    let mut envelope = chain();
578    let principal = envelope
579      .proof
580      .principal()
581      .expect("the chain fixture has a principal proof")
582      .clone();
583    envelope.proof = crate::envelope::EnvelopeProofs::Chain(std::vec![principal]);
584    let error = super::signing_base(&envelope, super::ProofRole::Principal)
585      .expect_err("a one-element chain is not a chain");
586    std::assert_eq!(error.code(), "APH_E013");
587  }
588
589  #[test]
590  fn notary_base_of_a_three_element_chain_is_aph_e013() {
591    // §7.1.11 fixes the chain at exactly two proofs. A longer chain has no
592    // defined base, and guessing (say, "sign the last one") would let a
593    // producer smuggle an extra proof under a real countersignature.
594    let mut envelope = chain();
595    let mut proofs = envelope.proof.all().to_vec();
596    let extra = proofs
597      .last()
598      .expect("the chain fixture is non-empty")
599      .clone();
600    proofs.push(extra);
601    envelope.proof = crate::envelope::EnvelopeProofs::Chain(proofs);
602    let error = super::signing_base(&envelope, super::ProofRole::Notary)
603      .expect_err("a three-element chain has no defined base");
604    std::assert_eq!(error.code(), "APH_E013");
605  }
606
607  #[test]
608  fn mandate_principal_base_removes_both_signature_fields() {
609    // §6.1: `principalSignature` covers the form MINUS both signatures. If
610    // either leaked into the base, the human could not sign the mandate
611    // before the notary countersigned it — the required order.
612    let base = super::mandate_signing_base(&sample_mandate(), super::ProofRole::Principal)
613      .expect("a mandate always has a principal base");
614    std::assert!(
615      !base.contains("principalSignature") && !base.contains("notarySignature"),
616      "principal mandate base must carry neither signature: {}",
617      base
618    );
619  }
620
621  #[test]
622  fn mandate_notary_base_keeps_the_principal_signature() {
623    // "The notary countersigns what the principal signed" (§6.1). The
624    // principal's signature must be inside the notary's covered bytes, or a
625    // notary signature could be moved onto a mandate the human never signed.
626    let base = super::mandate_signing_base(&sample_mandate(), super::ProofRole::Notary)
627      .expect("a mandate always has a notary base");
628    std::assert!(
629      base.contains("zPrincipalSignatureBytes") && !base.contains("notarySignature"),
630      "notary mandate base must keep principalSignature and drop its own: {}",
631      base
632    );
633  }
634
635  #[test]
636  fn the_two_mandate_bases_are_distinct() {
637    // Same domain-separation argument as the envelope bases: if the human's
638    // base equalled the notary's, one party's signature would verify as the
639    // other's and the countersignature would prove nothing.
640    let mandate = sample_mandate();
641    let principal = super::mandate_signing_base(&mandate, super::ProofRole::Principal)
642      .expect("principal mandate base");
643    let notary =
644      super::mandate_signing_base(&mandate, super::ProofRole::Notary).expect("notary mandate base");
645    std::assert_ne!(principal, notary);
646  }
647
648  #[test]
649  fn bases_are_deterministic() {
650    // Every signature in the protocol is over these bytes, so the same input
651    // must canonicalize identically on every call. A base that depended on
652    // map iteration order would verify locally and fail across machines.
653    let chained = chain();
654    let once = super::signing_base(&chained, super::ProofRole::Notary).expect("notary base");
655    let twice = super::signing_base(&chained, super::ProofRole::Notary).expect("notary base");
656    std::assert_eq!(once, twice);
657  }
658}