Skip to main content

polyc_crypto/
approval_assertion.rs

1//! Canonical signing for an approval's asserted responder (`#1553`).
2//!
3//! An [`AssertedApproval`] is the edge's signed statement of *which human*
4//! resolved a pending approval. It rides inside the
5//! [`ApprovalResponseRequest`] it describes, and it commits to that whole
6//! request: this module's [`APPROVAL_ASSERTION_DOMAIN_PREFIX`] followed by
7//! the buffa encoding of the request with the assertion's `signature_hex`
8//! cleared. Both signer and verifier clear that one field before encoding, so
9//! the signature covers every other field of the request — including the
10//! `turn_id`, the `decision` oneof, an `Approve`'s `modified_args_json` and
11//! `injected_context`, the `resolve_token`, and the asserted `responder` — but
12//! not itself.
13//!
14//! Signing the whole request rather than just the identity is the point.
15//! An assertion over `(turn_id, request_id, conversation_id, responder)` alone would
16//! leave a validly-signed envelope replayable with approve swapped for deny,
17//! or with the arguments that actually execute rewritten
18//! (`docs/reference/verified-approver.md`, decision 3). [`canonical_bytes`]
19//! clones the whole request and clears only that one field, so any field
20//! later added to either message is covered without further change here.
21//!
22//! The domain prefix is a sibling of
23//! [`crate::edge_identity`]'s `EDGE_IDENTITY_DOMAIN_PREFIX`: a signature
24//! minted as an edge-identity envelope never verifies as an approval
25//! assertion, and the reverse (INV-A4, proven by
26//! `edge_identity_signature_never_verifies_as_an_approval_assertion` and its
27//! mirror rather than assumed from the two constants differing).
28//!
29//! Use [`attach_approval_assertion`] to put a signed assertion on a request,
30//! or [`sign_approval_assertion`] for the signature alone;
31//! [`verify_approval_assertion`] checks it against the candidate signing keys
32//! the caller resolved for the asserting edge. The verifier never panics — a
33//! malformed `signature_hex` surfaces as `false`.
34
35use buffa::Message as _;
36use polyc_proto::proto::polychrome::approval::v1::{ApprovalResponseRequest, AssertedApproval};
37
38use crate::{Signer, verify};
39
40/// Domain-separation prefix prepended to an approval assertion's signed bytes
41/// (trailing NUL, exactly like `edge_identity`'s own prefix, so no legal
42/// buffa encoding can extend it into a prefix collision).
43///
44/// The exact bytes are load-bearing: changing them invalidates every
45/// currently-live approval assertion. Pinned in
46/// `polyc-conformance-vectors::APPROVAL_ASSERTION` (`crates/conformance-vectors/vectors/approval-assertion.json`).
47pub const APPROVAL_ASSERTION_DOMAIN_PREFIX: &[u8] = b"polychrome.approval-assertion.v1\0";
48
49/// Canonical bytes for the assertion carried on `request`.
50///
51/// [`APPROVAL_ASSERTION_DOMAIN_PREFIX`] followed by the encoding of the whole
52/// request with the assertion's `signature_hex` cleared, so the signature
53/// commits to everything *except* itself.
54///
55/// A request carrying no assertion still has canonical bytes — they simply
56/// describe a request with an absent assertion. That case is never signed in
57/// practice; [`verify_approval_assertion`] refuses it before reaching here.
58#[must_use]
59pub fn canonical_bytes(request: &ApprovalResponseRequest) -> Vec<u8> {
60    let mut canonical = request.clone();
61    if let Some(assertion) = canonical.asserted_approval.as_option_mut() {
62        assertion.signature_hex.clear();
63    }
64    let encoded = canonical.encode_to_vec();
65    let mut bytes = Vec::with_capacity(APPROVAL_ASSERTION_DOMAIN_PREFIX.len() + encoded.len());
66    bytes.extend_from_slice(APPROVAL_ASSERTION_DOMAIN_PREFIX);
67    bytes.extend_from_slice(&encoded);
68    bytes
69}
70
71/// Sign the canonical bytes of `request`; returns the lowercase-hex signature
72/// suitable for [`AssertedApproval::signature_hex`].
73///
74/// The request must already carry the assertion being signed — the signature
75/// covers the asserted `edge_id` and `responder` too. Prefer
76/// [`attach_approval_assertion`], which makes that ordering impossible to get
77/// wrong.
78#[must_use]
79pub fn sign_approval_assertion(signer: &Signer, request: &ApprovalResponseRequest) -> String {
80    crate::hex::lower(&signer.sign(&canonical_bytes(request)))
81}
82
83/// Put `assertion` on `request` and sign it in place.
84///
85/// Whatever `signature_hex` `assertion` arrives with is irrelevant:
86/// [`canonical_bytes`] clears the field before encoding, and the minted
87/// signature then replaces it.
88pub fn attach_approval_assertion(
89    signer: &Signer,
90    request: &mut ApprovalResponseRequest,
91    assertion: AssertedApproval,
92) {
93    request.asserted_approval = buffa::MessageField::some(assertion);
94    let signature = sign_approval_assertion(signer, request);
95    if let Some(attached) = request.asserted_approval.as_option_mut() {
96        attached.signature_hex = signature;
97    }
98}
99
100/// Verify the assertion carried on `request`.
101///
102/// `candidate_public_keys` are the enrolled signing keys the caller resolved
103/// for the asserting `AssertedApproval::edge_id`, encoded the way [`verify`]
104/// expects.
105///
106/// Returns `false` when the request carries no assertion, when its
107/// `signature_hex` is not valid hex, or when no candidate key verifies the
108/// signature — never panics. Every candidate is checked with no early exit,
109/// mirroring `ingest_gate`'s edge-envelope scan, so the number of candidates
110/// examined does not become a signal for which key matched.
111///
112/// A caller that needs to know *which* key matched (to prefer an `Active` key
113/// over a `Retiring` one, as edge-envelope verification does) calls this once
114/// per candidate instead.
115#[must_use]
116pub fn verify_approval_assertion<'a, K>(
117    candidate_public_keys: K,
118    request: &ApprovalResponseRequest,
119) -> bool
120where
121    K: IntoIterator<Item = &'a [u8]>,
122{
123    let Some(assertion) = request.asserted_approval.as_option() else {
124        return false;
125    };
126    let Some(signature) = crate::hex::decode(&assertion.signature_hex) else {
127        return false;
128    };
129    let bytes = canonical_bytes(request);
130    let mut verified = false;
131    for public_key in candidate_public_keys {
132        if verify(public_key, &bytes, &signature) {
133            verified = true;
134        }
135    }
136    verified
137}
138
139#[cfg(test)]
140mod tests {
141    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
142
143    use polyc_proto::proto::polychrome::approval::v1::{
144        Approve, Deny, approval_response_request::Decision,
145    };
146    use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
147
148    use super::*;
149
150    fn sample_responder() -> ExternalIdentity {
151        ExternalIdentity {
152            provider: "chat".to_owned(),
153            scope: "team-1".to_owned(),
154            external_id: "U123".to_owned(),
155            display_name: "Ada".to_owned(),
156            ..Default::default()
157        }
158    }
159
160    fn sample_assertion() -> AssertedApproval {
161        AssertedApproval {
162            edge_id: "chat".to_owned(),
163            responder: buffa::MessageField::some(sample_responder()),
164            ..Default::default()
165        }
166    }
167
168    fn sample_request() -> ApprovalResponseRequest {
169        ApprovalResponseRequest {
170            turn_id: "018f47f0-5f70-7cc5-98df-123456789abc".to_owned(),
171            request_id: "req-1".to_owned(),
172            conversation_id: "chat:team-1:general".to_owned(),
173            resolve_token: "token-1".to_owned(),
174            decision: Some(Decision::Approve(Box::new(Approve {
175                reason: "looks right".to_owned(),
176                ..Default::default()
177            }))),
178            ..Default::default()
179        }
180    }
181
182    fn signed_request(signer: &Signer) -> ApprovalResponseRequest {
183        let mut request = sample_request();
184        attach_approval_assertion(signer, &mut request, sample_assertion());
185        request
186    }
187
188    /// Candidate-key list of one, the shape every test below verifies with.
189    fn keys(public_key: &[u8]) -> [&[u8]; 1] {
190        [public_key]
191    }
192
193    #[test]
194    fn round_trips() {
195        let signer = Signer::from_seed(41);
196        let pk = signer.public_key_bytes();
197        let request = signed_request(&signer);
198        assert!(
199            !request
200                .asserted_approval
201                .as_option()
202                .unwrap()
203                .signature_hex
204                .is_empty()
205        );
206        assert!(verify_approval_assertion(keys(&pk), &request));
207    }
208
209    #[test]
210    fn tampered_responder_fails() {
211        let signer = Signer::from_seed(41);
212        let pk = signer.public_key_bytes();
213        let mut request = signed_request(&signer);
214        request.asserted_approval.as_option_mut().unwrap().responder =
215            buffa::MessageField::some(ExternalIdentity {
216                external_id: "U999".to_owned(),
217                ..sample_responder()
218            });
219        assert!(
220            !verify_approval_assertion(keys(&pk), &request),
221            "swapping the asserted responder must invalidate the signature (INV-A3)"
222        );
223    }
224
225    #[test]
226    fn tampered_decision_fails() {
227        // Decision 3: an assertion that covered only the identity would stay
228        // valid with approve swapped for deny. The canonical bytes are the
229        // whole request, so it does not.
230        let signer = Signer::from_seed(41);
231        let pk = signer.public_key_bytes();
232        let mut request = signed_request(&signer);
233        request.decision = Some(Decision::Deny(Box::new(Deny {
234            reason: "looks right".to_owned(),
235            ..Default::default()
236        })));
237        assert!(
238            !verify_approval_assertion(keys(&pk), &request),
239            "swapping approve for deny must invalidate the signature (INV-A5)"
240        );
241    }
242
243    #[test]
244    fn tampered_modified_args_fails() {
245        // The other half of decision 3: the decision variant is unchanged,
246        // but what actually executes is not.
247        let signer = Signer::from_seed(41);
248        let pk = signer.public_key_bytes();
249        let mut request = signed_request(&signer);
250        request.decision = Some(Decision::Approve(Box::new(Approve {
251            modified_args_json: r#"{"path":"/"}"#.to_owned(),
252            reason: "looks right".to_owned(),
253            ..Default::default()
254        })));
255        assert!(!verify_approval_assertion(keys(&pk), &request));
256    }
257
258    #[test]
259    fn tampered_occurrence_identity_fails() {
260        let signer = Signer::from_seed(41);
261        let pk = signer.public_key_bytes();
262        let mut request = signed_request(&signer);
263        request.turn_id = "018f47f0-5f70-7cc5-98df-123456789abd".to_owned();
264        assert!(
265            !verify_approval_assertion(keys(&pk), &request),
266            "an assertion is bound to one approval occurrence's turn (INV-A5)"
267        );
268
269        let mut request = signed_request(&signer);
270        request.request_id = "req-2".to_owned();
271        assert!(
272            !verify_approval_assertion(keys(&pk), &request),
273            "an assertion is bound to one request_id (INV-A5)"
274        );
275    }
276
277    #[test]
278    fn tampered_conversation_id_fails() {
279        let signer = Signer::from_seed(41);
280        let pk = signer.public_key_bytes();
281        let mut request = signed_request(&signer);
282        request.conversation_id = "chat:team-1:secrets".to_owned();
283        assert!(
284            !verify_approval_assertion(keys(&pk), &request),
285            "an assertion is bound to one conversation_id (INV-A5)"
286        );
287    }
288
289    #[test]
290    fn tampered_resolve_token_fails() {
291        let signer = Signer::from_seed(41);
292        let pk = signer.public_key_bytes();
293        let mut request = signed_request(&signer);
294        request.resolve_token = "token-2".to_owned();
295        assert!(!verify_approval_assertion(keys(&pk), &request));
296    }
297
298    #[test]
299    fn tampered_edge_id_fails() {
300        let signer = Signer::from_seed(41);
301        let pk = signer.public_key_bytes();
302        let mut request = signed_request(&signer);
303        request.asserted_approval.as_option_mut().unwrap().edge_id = "messaging".to_owned();
304        assert!(!verify_approval_assertion(keys(&pk), &request));
305    }
306
307    #[test]
308    fn wrong_key_fails() {
309        let signer = Signer::from_seed(41);
310        let other = Signer::from_seed(42);
311        let request = signed_request(&signer);
312        assert!(!verify_approval_assertion(
313            keys(&other.public_key_bytes()),
314            &request
315        ));
316    }
317
318    #[test]
319    fn verifies_against_any_candidate_key() {
320        // Rotation overlap: the caller hands over every candidate key and one
321        // of them is the signer's.
322        let signer = Signer::from_seed(41);
323        let retiring = Signer::from_seed(42);
324        let retiring_pk = retiring.public_key_bytes();
325        let active_pk = signer.public_key_bytes();
326        let request = signed_request(&signer);
327        let candidates: [&[u8]; 2] = [&retiring_pk, &active_pk];
328        assert!(verify_approval_assertion(candidates, &request));
329    }
330
331    #[test]
332    fn no_candidate_keys_fails() {
333        let signer = Signer::from_seed(41);
334        let request = signed_request(&signer);
335        let candidates: [&[u8]; 0] = [];
336        assert!(!verify_approval_assertion(candidates, &request));
337    }
338
339    #[test]
340    fn absent_assertion_fails() {
341        // Decision 4: no assertion is not an error here, just "unverified".
342        let signer = Signer::from_seed(41);
343        let pk = signer.public_key_bytes();
344        let request = sample_request();
345        assert!(!verify_approval_assertion(keys(&pk), &request));
346    }
347
348    #[test]
349    fn unsigned_assertion_fails() {
350        let signer = Signer::from_seed(41);
351        let pk = signer.public_key_bytes();
352        let mut request = sample_request();
353        request.asserted_approval = buffa::MessageField::some(sample_assertion());
354        assert!(!verify_approval_assertion(keys(&pk), &request));
355    }
356
357    #[test]
358    fn garbage_signature_hex_returns_false_not_panic() {
359        let signer = Signer::from_seed(41);
360        let pk = signer.public_key_bytes();
361        let mut request = signed_request(&signer);
362        for garbage in ["not-hex!", "abc", "", "zz", &"ab".repeat(4096)] {
363            request
364                .asserted_approval
365                .as_option_mut()
366                .unwrap()
367                .signature_hex = garbage.to_owned();
368            assert!(
369                !verify_approval_assertion(keys(&pk), &request),
370                "malformed signature_hex {garbage:?} must return false, not panic"
371            );
372        }
373    }
374
375    #[test]
376    fn a_stale_signature_does_not_change_the_minted_one() {
377        // `canonical_bytes` clears `signature_hex` before encoding, so an
378        // assertion arriving with a leftover (or attacker-chosen) signature
379        // mints the identical signature one arriving clean does. Without that
380        // clear, the signature would depend on the value it replaces and no
381        // two signers would agree on the same request.
382        let signer = Signer::from_seed(41);
383        let pk = signer.public_key_bytes();
384
385        let mut clean = sample_request();
386        attach_approval_assertion(&signer, &mut clean, sample_assertion());
387
388        let mut stale = sample_request();
389        attach_approval_assertion(
390            &signer,
391            &mut stale,
392            AssertedApproval {
393                signature_hex: "deadbeef".to_owned(),
394                ..sample_assertion()
395            },
396        );
397
398        assert_eq!(
399            clean.asserted_approval.as_option().unwrap().signature_hex,
400            stale.asserted_approval.as_option().unwrap().signature_hex
401        );
402        assert!(verify_approval_assertion(keys(&pk), &stale));
403    }
404
405    #[test]
406    fn undomained_signature_is_rejected() {
407        // A signature the SAME signer minted over the RAW request encoding
408        // (no domain prefix at all) must never verify.
409        let signer = Signer::from_seed(41);
410        let pk = signer.public_key_bytes();
411        let mut request = sample_request();
412        request.asserted_approval = buffa::MessageField::some(sample_assertion());
413        let raw = request.encode_to_vec();
414        request
415            .asserted_approval
416            .as_option_mut()
417            .unwrap()
418            .signature_hex = crate::hex::lower(&signer.sign(&raw));
419        assert!(!verify_approval_assertion(keys(&pk), &request));
420    }
421
422    #[test]
423    fn edge_identity_signature_never_verifies_as_an_approval_assertion() {
424        // INV-A4, forward direction. The signer mints a signature over this
425        // request's canonical payload but under the EDGE-IDENTITY domain
426        // prefix — the exact confusion a shared prefix would allow. Uses the
427        // real `edge_identity` constant, not a copy.
428        let signer = Signer::from_seed(41);
429        let pk = signer.public_key_bytes();
430        let mut request = sample_request();
431        request.asserted_approval = buffa::MessageField::some(sample_assertion());
432        let encoded = request.encode_to_vec();
433
434        let mut cross_domain = Vec::new();
435        cross_domain.extend_from_slice(crate::edge_identity::EDGE_IDENTITY_DOMAIN_PREFIX);
436        cross_domain.extend_from_slice(&encoded);
437        request
438            .asserted_approval
439            .as_option_mut()
440            .unwrap()
441            .signature_hex = crate::hex::lower(&signer.sign(&cross_domain));
442
443        assert!(
444            !verify_approval_assertion(keys(&pk), &request),
445            "a signature minted under the edge-identity prefix must not verify as an approval \
446             assertion (INV-A4)"
447        );
448
449        // The same signer over the SAME payload under the approval prefix
450        // does verify — proving the rejection above is the prefix doing the
451        // work, not an unrelated mismatch.
452        let mut same_domain = Vec::new();
453        same_domain.extend_from_slice(APPROVAL_ASSERTION_DOMAIN_PREFIX);
454        same_domain.extend_from_slice(&encoded);
455        request
456            .asserted_approval
457            .as_option_mut()
458            .unwrap()
459            .signature_hex = crate::hex::lower(&signer.sign(&same_domain));
460        assert!(verify_approval_assertion(keys(&pk), &request));
461    }
462
463    #[test]
464    fn approval_assertion_signature_never_verifies_as_an_edge_identity_envelope() {
465        // INV-A4, reverse direction: a signature minted under the APPROVAL
466        // prefix must not verify as an edge-identity envelope.
467        use polyc_proto::proto::polychrome::agent::v1::AssertedAttribution;
468
469        let signer = Signer::from_seed(41);
470        let pk = signer.public_key_bytes();
471        let mut envelope = AssertedAttribution {
472            edge_id: "chat".to_owned(),
473            conversation_id: "chat:team-1:general".to_owned(),
474            nonce: "nonce-1".to_owned(),
475            issued_unix_ms: 1_700_000_000_000,
476            caller: buffa::MessageField::some(sample_responder()),
477            exec_id: "exec-1".to_owned(),
478            content_hash: crate::edge_identity::content_hash_hex(b"messages-bytes"),
479            ..Default::default()
480        };
481        let encoded = envelope.encode_to_vec();
482
483        let mut cross_domain = Vec::new();
484        cross_domain.extend_from_slice(APPROVAL_ASSERTION_DOMAIN_PREFIX);
485        cross_domain.extend_from_slice(&encoded);
486        envelope.signature_hex = crate::hex::lower(&signer.sign(&cross_domain));
487        assert!(
488            !crate::edge_identity::verify_edge_assertion(&pk, &envelope),
489            "a signature minted under the approval-assertion prefix must not verify as an \
490             edge-identity envelope (INV-A4)"
491        );
492
493        // Same signer, same payload, correct prefix: verifies. Isolates the
494        // rejection above to the domain separation.
495        crate::edge_identity::sign_edge_assertion_into(&signer, &mut envelope);
496        assert!(crate::edge_identity::verify_edge_assertion(&pk, &envelope));
497    }
498
499    #[test]
500    fn the_two_domain_prefixes_are_distinct_and_nul_terminated() {
501        assert_ne!(
502            APPROVAL_ASSERTION_DOMAIN_PREFIX,
503            crate::edge_identity::EDGE_IDENTITY_DOMAIN_PREFIX
504        );
505        assert_eq!(APPROVAL_ASSERTION_DOMAIN_PREFIX.last(), Some(&0u8));
506    }
507}
508
509/// Drives `polyc-conformance-vectors::APPROVAL_ASSERTION` (`crates/conformance-vectors/vectors/approval-assertion.json`) — the pinned,
510/// implementation-independent vectors for this artifact, so a future port in
511/// another language cannot silently disagree about the canonical bytes, the
512/// domain prefix, or which mutations must break a signature.
513#[cfg(test)]
514mod conformance {
515    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
516
517    use buffa::Message as _;
518    use polyc_proto::proto::polychrome::approval::v1::{
519        Approve, Deny, approval_response_request::Decision,
520    };
521    use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
522    use serde_json::Value;
523
524    use super::*;
525
526    const VECTORS: &str = polyc_conformance_vectors::APPROVAL_ASSERTION;
527
528    fn vectors() -> Value {
529        serde_json::from_str(VECTORS).expect("the conformance vector file is valid JSON")
530    }
531
532    fn hex_of(v: &Value, path: &[&str]) -> Vec<u8> {
533        let mut node = v;
534        for key in path {
535            node = &node[*key];
536        }
537        crate::hex::decode(node.as_str().expect("a hex string")).expect("valid hex")
538    }
539
540    /// The vector's request, rebuilt field by field from the JSON — so the
541    /// test proves the encoder agrees with the vector, not merely that the
542    /// vector agrees with itself.
543    fn vector_request(v: &Value) -> ApprovalResponseRequest {
544        let r = &v["vector"]["request"];
545        let approve = &r["decision"]["Approve"];
546        let responder = &r["asserted_approval"]["responder"];
547        ApprovalResponseRequest {
548            request_id: r["request_id"].as_str().unwrap().to_owned(),
549            conversation_id: r["conversation_id"].as_str().unwrap().to_owned(),
550            resolve_token: r["resolve_token"].as_str().unwrap().to_owned(),
551            decision: Some(Decision::Approve(Box::new(Approve {
552                modified_args_json: approve["modified_args_json"].as_str().unwrap().to_owned(),
553                injected_context: approve["injected_context"].as_str().unwrap().to_owned(),
554                reason: approve["reason"].as_str().unwrap().to_owned(),
555                approved_for_session: approve["approved_for_session"].as_bool().unwrap(),
556                ..Default::default()
557            }))),
558            asserted_approval: buffa::MessageField::some(AssertedApproval {
559                edge_id: r["asserted_approval"]["edge_id"]
560                    .as_str()
561                    .unwrap()
562                    .to_owned(),
563                responder: buffa::MessageField::some(ExternalIdentity {
564                    provider: responder["provider"].as_str().unwrap().to_owned(),
565                    scope: responder["scope"].as_str().unwrap().to_owned(),
566                    external_id: responder["external_id"].as_str().unwrap().to_owned(),
567                    display_name: responder["display_name"].as_str().unwrap().to_owned(),
568                    time_zone: responder["time_zone"].as_str().unwrap().to_owned(),
569                    ..Default::default()
570                }),
571                ..Default::default()
572            }),
573            ..Default::default()
574        }
575    }
576
577    #[test]
578    fn the_pinned_domain_prefix_is_the_one_this_crate_uses() {
579        let v = vectors();
580        assert_eq!(
581            v["domain_separation"]["approval_assertion_prefix_ascii"]
582                .as_str()
583                .unwrap()
584                .as_bytes(),
585            APPROVAL_ASSERTION_DOMAIN_PREFIX
586        );
587        assert_eq!(
588            hex_of(&v, &["domain_separation", "approval_assertion_prefix_hex"]),
589            APPROVAL_ASSERTION_DOMAIN_PREFIX
590        );
591        assert_eq!(
592            v["domain_separation"]["edge_identity_prefix_ascii"]
593                .as_str()
594                .unwrap()
595                .as_bytes(),
596            crate::edge_identity::EDGE_IDENTITY_DOMAIN_PREFIX
597        );
598        assert_eq!(
599            v["signature_scheme"]["namespace_ascii"]
600                .as_str()
601                .unwrap()
602                .as_bytes(),
603            crate::NAMESPACE
604        );
605    }
606
607    #[test]
608    fn the_known_good_vector_reproduces_its_bytes_and_signature() {
609        let v = vectors();
610        let request = vector_request(&v);
611
612        assert_eq!(
613            request.encode_to_vec(),
614            hex_of(&v, &["vector", "expected_request_encoding_hex"]),
615            "the buffa encoding of the vector's request must match the pinned bytes"
616        );
617
618        let canonical = canonical_bytes(&request);
619        assert_eq!(
620            canonical,
621            hex_of(&v, &["vector", "expected_canonical_bytes_hex"]),
622            "canonical bytes must be the domain prefix followed by that encoding"
623        );
624
625        // The exact ed25519 preimage: commonware's union_unique, whose
626        // namespace length varint is a single byte at this length.
627        let mut preimage = vec![u8::try_from(crate::NAMESPACE.len()).unwrap()];
628        preimage.extend_from_slice(crate::NAMESPACE);
629        preimage.extend_from_slice(&canonical);
630        assert_eq!(
631            preimage,
632            hex_of(&v, &["vector", "expected_ed25519_message_hex"])
633        );
634
635        let private_key = hex_of(&v, &["signer", "ed25519_private_key_hex"]);
636        let signer = Signer::from_key_bytes(&private_key).expect("valid key material");
637        let public_key = signer.public_key_bytes();
638        assert_eq!(
639            public_key,
640            hex_of(&v, &["signer", "ed25519_public_key_hex"]),
641            "the pinned public key must be the one the pinned private key derives"
642        );
643
644        let expected_signature = v["vector"]["expected_signature_hex"].as_str().unwrap();
645        assert_eq!(
646            sign_approval_assertion(&signer, &request),
647            expected_signature,
648            "signing the vector's request must reproduce the pinned signature"
649        );
650
651        let mut signed = request;
652        signed
653            .asserted_approval
654            .as_option_mut()
655            .unwrap()
656            .signature_hex = expected_signature.to_owned();
657        assert!(
658            verify_approval_assertion([public_key.as_slice()], &signed),
659            "the known-good vector must verify"
660        );
661    }
662
663    /// Applies each `must_not_verify` mutation to the known-good request,
664    /// checks the mutated request encodes to the bytes the vector pinned for
665    /// it (so the vector and this code describe the same mutation), and then
666    /// checks the pinned signature no longer verifies.
667    #[test]
668    fn every_pinned_mutation_breaks_verification() {
669        let v = vectors();
670        let private_key = hex_of(&v, &["signer", "ed25519_private_key_hex"]);
671        let signer = Signer::from_key_bytes(&private_key).expect("valid key material");
672        let public_key = signer.public_key_bytes();
673        let good_signature = v["vector"]["expected_signature_hex"].as_str().unwrap();
674
675        let base = {
676            let mut base = vector_request(&v);
677            base.asserted_approval
678                .as_option_mut()
679                .unwrap()
680                .signature_hex = good_signature.to_owned();
681            base
682        };
683
684        let cases = v["must_not_verify"].as_array().expect("a list of cases");
685        assert_eq!(cases.len(), 6, "every pinned case must be exercised");
686
687        for case in cases {
688            let id = case["id"].as_str().unwrap();
689            let mut request = base.clone();
690
691            match id {
692                "tampered-responder" => {
693                    request
694                        .asserted_approval
695                        .as_option_mut()
696                        .unwrap()
697                        .responder
698                        .as_option_mut()
699                        .unwrap()
700                        .external_id = "U0EVE".to_owned();
701                }
702                "tampered-decision" => {
703                    request.decision = Some(Decision::Deny(Box::new(Deny {
704                        reason: "approved by Ada".to_owned(),
705                        ..Default::default()
706                    })));
707                }
708                "tampered-modified-args" => {
709                    let Some(Decision::Approve(approve)) = request.decision.as_mut() else {
710                        panic!("the vector's decision is an Approve");
711                    };
712                    approve.modified_args_json = r#"{"path":"/etc/shadow"}"#.to_owned();
713                }
714                "tampered-resolve-token" => {
715                    request.resolve_token = "resolve-token-conformance-2".to_owned();
716                }
717                "cross-domain-edge-identity" | "malformed-signature-hex" => {
718                    // These leave the request alone and change only the
719                    // signature; handled below.
720                }
721                other => panic!("unhandled conformance case {other}"),
722            }
723
724            assert_eq!(
725                crate::hex::lower(&canonical_bytes(&request)),
726                case["canonical_bytes_hex"].as_str().unwrap(),
727                "case {id}: the mutation applied here must be the one the vector pinned"
728            );
729
730            let signatures: Vec<String> = case["signature_hex"].as_str().map_or_else(
731                || {
732                    case["signature_hex_values"]
733                        .as_array()
734                        .expect("one of signature_hex / signature_hex_values")
735                        .iter()
736                        .map(|s| s.as_str().unwrap().to_owned())
737                        .collect()
738                },
739                |s| vec![s.to_owned()],
740            );
741
742            for signature in signatures {
743                request
744                    .asserted_approval
745                    .as_option_mut()
746                    .unwrap()
747                    .signature_hex = signature.clone();
748                assert!(
749                    !verify_approval_assertion([public_key.as_slice()], &request),
750                    "case {id}: signature {signature:?} must not verify"
751                );
752            }
753        }
754    }
755}