1use buffa::Message as _;
36use polyc_proto::proto::polychrome::approval::v1::{ApprovalResponseRequest, AssertedApproval};
37
38use crate::{Signer, verify};
39
40pub const APPROVAL_ASSERTION_DOMAIN_PREFIX: &[u8] = b"polychrome.approval-assertion.v1\0";
48
49#[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#[must_use]
79pub fn sign_approval_assertion(signer: &Signer, request: &ApprovalResponseRequest) -> String {
80 crate::hex::lower(&signer.sign(&canonical_bytes(request)))
81}
82
83pub 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#[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 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 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 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 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 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 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 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 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 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 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 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#[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 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 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 #[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 }
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}