1use serde::Serialize;
33use serde_json::Value;
34use sha2::{Digest, Sha256};
35
36use base64::Engine as _;
37use base64::engine::general_purpose::URL_SAFE_NO_PAD;
38
39use crate::signed::{Envelope, canonical_bytes};
40use crate::{Signer, verify};
41use polyc_passkey::derive::verify_derived_signature;
42use polyc_passkey::{Assertion, WebAuthnProof, challenge_b64, verify_assertion};
43
44pub const GRANT_KIND: &str = "grant.routine.v1";
46pub const REVOCATION_KIND: &str = "grant.revocation.v1";
48pub const SUSPENSION_KIND: &str = "grant.suspension.v1";
50pub const GRANT_PERSONA_KIND: &str = "grant.routine.persona.v1";
58pub const REVOCATION_PERSONA_KIND: &str = "grant.revocation.persona.v1";
62
63#[derive(Debug, Clone, Copy)]
70pub struct PersonaProof<'a> {
71 pub signing_public_key: &'a [u8],
73 pub signature: &'a [u8],
76}
77
78#[derive(Debug, Clone, Copy)]
88pub struct GrantCoverage<'a> {
89 pub destination: &'a str,
100 pub slot_schema_hash: &'a str,
102 pub covered_capabilities: &'a [String],
104}
105
106#[must_use]
114pub fn grant_canonical(
115 principal: &str,
116 routine: &str,
117 tool: &str,
118 coverage: &GrantCoverage<'_>,
119 enrollment_id: &str,
120 created_at_ms: u64,
121) -> Vec<u8> {
122 canonical_bytes(&GrantCanonical {
123 kind: GRANT_KIND,
124 principal,
125 routine,
126 tool,
127 destination: coverage.destination,
128 slot_schema_hash: coverage.slot_schema_hash,
129 covered_capabilities: coverage.covered_capabilities,
130 enrollment_id,
131 created_at_ms,
132 })
133}
134
135#[derive(Serialize)]
143struct GrantCanonical<'a> {
144 kind: &'static str,
145 principal: &'a str,
146 routine: &'a str,
147 tool: &'a str,
148 destination: &'a str,
149 slot_schema_hash: &'a str,
150 covered_capabilities: &'a [String],
151 enrollment_id: &'a str,
152 created_at_ms: u64,
153}
154
155#[derive(Serialize)]
160struct RevocationCanonical<'a> {
161 kind: &'static str,
162 grant_ref: &'a str,
163 revoked_at_ms: u64,
164}
165
166#[derive(Serialize)]
173struct SuspensionCanonical<'a> {
174 kind: &'static str,
175 grant_ref: &'a str,
176 reason: &'a str,
177 suspended_at_ms: u64,
178}
179
180#[must_use]
188pub fn grant_challenge(canonical: &[u8]) -> String {
189 challenge_b64(canonical)
190}
191
192#[must_use]
202pub fn grant_ref(signed_grant_payload: &[u8]) -> String {
203 crate::hex::lower(&Sha256::digest(signed_grant_payload))
204}
205
206#[must_use]
215pub fn signed_grant_payload(
216 principal: &str,
217 routine: &str,
218 tool: &str,
219 coverage: &GrantCoverage<'_>,
220 enrollment_id: &str,
221 created_at_ms: u64,
222 proof: &WebAuthnProof<'_>,
223) -> Vec<u8> {
224 let canonical = grant_canonical(
225 principal,
226 routine,
227 tool,
228 coverage,
229 enrollment_id,
230 created_at_ms,
231 );
232 attach_proof(&canonical, proof)
233}
234
235fn attach_proof(canonical: &[u8], proof: &WebAuthnProof<'_>) -> Vec<u8> {
276 let mut map: serde_json::Map<String, Value> =
281 serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
282 map.insert(
283 "credential_id".to_owned(),
284 Value::String(b64(proof.credential_id)),
285 );
286 map.insert(
287 "authenticator_data".to_owned(),
288 Value::String(b64(proof.authenticator_data)),
289 );
290 map.insert(
291 "client_data_json".to_owned(),
292 Value::String(b64(proof.client_data_json)),
293 );
294 map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
295 Value::Object(map).to_string().into_bytes() }
297
298fn verify_passkey_proof(
310 v: &Value,
311 canonical: &[u8],
312 registered_pubkey_sec1: &[u8],
313 rp_id: &str,
314 origin: &str,
315) -> Option<()> {
316 let authenticator_data = b64_decode(v.get("authenticator_data")?.as_str()?)?;
317 let client_data_json = b64_decode(v.get("client_data_json")?.as_str()?)?;
318 let signature = b64_decode(v.get("signature")?.as_str()?)?;
319 let assertion = Assertion {
320 authenticator_data: &authenticator_data,
321 client_data_json: &client_data_json,
322 signature: &signature,
323 };
324 verify_assertion(
325 registered_pubkey_sec1,
326 &assertion,
327 &grant_challenge(canonical),
328 rp_id,
329 origin,
330 )
331 .ok()
332 .map(|_checks| ())
333}
334
335fn attach_persona_proof(canonical: &[u8], proof: &PersonaProof<'_>) -> Vec<u8> {
346 let mut map: serde_json::Map<String, Value> =
348 serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
349 map.insert(
350 "signing_public_key".to_owned(),
351 Value::String(b64(proof.signing_public_key)),
352 );
353 map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
354 Value::Object(map).to_string().into_bytes() }
356
357fn verify_persona_proof(v: &Value, canonical: &[u8], expected_persona_pubkey: &[u8]) -> Option<()> {
372 let signing_public_key = b64_decode(v.get("signing_public_key")?.as_str()?)?;
373 let signature = b64_decode(v.get("signature")?.as_str()?)?;
374 if signing_public_key != expected_persona_pubkey {
375 return None;
376 }
377 if verify_derived_signature(&signing_public_key, canonical, &signature) {
378 Some(())
379 } else {
380 None
381 }
382}
383
384#[must_use]
390pub fn grant_canonical_persona(
391 principal: &str,
392 routine: &str,
393 tool: &str,
394 coverage: &GrantCoverage<'_>,
395 enrollment_id: &str,
396 created_at_ms: u64,
397) -> Vec<u8> {
398 canonical_bytes(&GrantCanonical {
399 kind: GRANT_PERSONA_KIND,
400 principal,
401 routine,
402 tool,
403 destination: coverage.destination,
404 slot_schema_hash: coverage.slot_schema_hash,
405 covered_capabilities: coverage.covered_capabilities,
406 enrollment_id,
407 created_at_ms,
408 })
409}
410
411#[must_use]
420pub fn signed_grant_payload_persona(
421 principal: &str,
422 routine: &str,
423 tool: &str,
424 coverage: &GrantCoverage<'_>,
425 enrollment_id: &str,
426 created_at_ms: u64,
427 proof: &PersonaProof<'_>,
428) -> Vec<u8> {
429 let canonical = grant_canonical_persona(
430 principal,
431 routine,
432 tool,
433 coverage,
434 enrollment_id,
435 created_at_ms,
436 );
437 attach_persona_proof(&canonical, proof)
438}
439
440#[must_use]
453pub fn verify_persona_signed_grant(
454 payload: &[u8],
455 expected_persona_pubkey: &[u8],
456) -> Option<VerifiedGrant> {
457 let v: Value = serde_json::from_slice(payload).ok()?;
458 if v.get("kind")?.as_str()? != GRANT_PERSONA_KIND {
459 return None;
460 }
461 let principal = v.get("principal")?.as_str()?.to_owned();
462 let routine = v.get("routine")?.as_str()?.to_owned();
463 let tool = v.get("tool")?.as_str()?.to_owned();
464 let destination = v.get("destination")?.as_str()?.to_owned();
465 let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
466 let covered_capabilities: Vec<String> = v
467 .get("covered_capabilities")?
468 .as_array()?
469 .iter()
470 .map(|c| c.as_str().map(str::to_owned))
471 .collect::<Option<Vec<_>>>()?;
472 let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
473 let created_at_ms = v.get("created_at_ms")?.as_u64()?;
474
475 let coverage = GrantCoverage {
476 destination: &destination,
477 slot_schema_hash: &slot_schema_hash,
478 covered_capabilities: &covered_capabilities,
479 };
480 let canonical = grant_canonical_persona(
481 &principal,
482 &routine,
483 &tool,
484 &coverage,
485 &enrollment_id,
486 created_at_ms,
487 );
488 verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
489
490 Some(VerifiedGrant {
491 principal,
492 routine,
493 tool,
494 destination,
495 slot_schema_hash,
496 covered_capabilities,
497 enrollment_id,
498 created_at_ms,
499 grant_ref: grant_ref(payload),
500 registered_public_key: expected_persona_pubkey.to_vec(),
501 })
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct VerifiedGrant {
507 pub principal: String,
511 pub routine: String,
513 pub tool: String,
515 pub destination: String,
517 pub slot_schema_hash: String,
519 pub covered_capabilities: Vec<String>,
521 pub enrollment_id: String,
523 pub created_at_ms: u64,
525 pub grant_ref: String,
528 pub registered_public_key: Vec<u8>,
530}
531
532#[must_use]
542pub fn verify_grant(
543 payload: &[u8],
544 registered_pubkey_sec1: &[u8],
545 rp_id: &str,
546 origin: &str,
547) -> Option<VerifiedGrant> {
548 let v: Value = serde_json::from_slice(payload).ok()?;
549 if v.get("kind")?.as_str()? != GRANT_KIND {
550 return None;
551 }
552 let principal = v.get("principal")?.as_str()?.to_owned();
553 let routine = v.get("routine")?.as_str()?.to_owned();
554 let tool = v.get("tool")?.as_str()?.to_owned();
555 let destination = v.get("destination")?.as_str()?.to_owned();
556 let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
557 let covered_capabilities: Vec<String> = v
558 .get("covered_capabilities")?
559 .as_array()?
560 .iter()
561 .map(|c| c.as_str().map(str::to_owned))
562 .collect::<Option<Vec<_>>>()?;
563 let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
564 let created_at_ms = v.get("created_at_ms")?.as_u64()?;
565
566 let coverage = GrantCoverage {
567 destination: &destination,
568 slot_schema_hash: &slot_schema_hash,
569 covered_capabilities: &covered_capabilities,
570 };
571 let canonical = grant_canonical(
572 &principal,
573 &routine,
574 &tool,
575 &coverage,
576 &enrollment_id,
577 created_at_ms,
578 );
579 verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
580
581 Some(VerifiedGrant {
582 principal,
583 routine,
584 tool,
585 destination,
586 slot_schema_hash,
587 covered_capabilities,
588 enrollment_id,
589 created_at_ms,
590 grant_ref: grant_ref(payload),
591 registered_public_key: registered_pubkey_sec1.to_vec(),
592 })
593}
594
595#[must_use]
601pub fn revocation_canonical(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
602 canonical_bytes(&RevocationCanonical {
603 kind: REVOCATION_KIND,
604 grant_ref,
605 revoked_at_ms,
606 })
607}
608
609#[must_use]
615pub fn signed_revocation_payload(
616 grant_ref: &str,
617 revoked_at_ms: u64,
618 proof: &WebAuthnProof<'_>,
619) -> Vec<u8> {
620 let canonical = revocation_canonical(grant_ref, revoked_at_ms);
621 attach_proof(&canonical, proof)
622}
623
624#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct VerifiedRevocation {
627 pub grant_ref: String,
629 pub revoked_at_ms: u64,
631 pub registered_public_key: Vec<u8>,
633}
634
635#[must_use]
643pub fn verify_revocation(
644 payload: &[u8],
645 registered_pubkey_sec1: &[u8],
646 rp_id: &str,
647 origin: &str,
648) -> Option<VerifiedRevocation> {
649 let v: Value = serde_json::from_slice(payload).ok()?;
650 if v.get("kind")?.as_str()? != REVOCATION_KIND {
651 return None;
652 }
653 let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
654 let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
655
656 let canonical = revocation_canonical(&grant_ref, revoked_at_ms);
657 verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
658
659 Some(VerifiedRevocation {
660 grant_ref,
661 revoked_at_ms,
662 registered_public_key: registered_pubkey_sec1.to_vec(),
663 })
664}
665
666#[must_use]
671pub fn revocation_canonical_persona(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
672 canonical_bytes(&RevocationCanonical {
673 kind: REVOCATION_PERSONA_KIND,
674 grant_ref,
675 revoked_at_ms,
676 })
677}
678
679#[must_use]
684pub fn signed_revocation_payload_persona(
685 grant_ref: &str,
686 revoked_at_ms: u64,
687 proof: &PersonaProof<'_>,
688) -> Vec<u8> {
689 let canonical = revocation_canonical_persona(grant_ref, revoked_at_ms);
690 attach_persona_proof(&canonical, proof)
691}
692
693#[must_use]
700pub fn verify_persona_signed_revocation(
701 payload: &[u8],
702 expected_persona_pubkey: &[u8],
703) -> Option<VerifiedRevocation> {
704 let v: Value = serde_json::from_slice(payload).ok()?;
705 if v.get("kind")?.as_str()? != REVOCATION_PERSONA_KIND {
706 return None;
707 }
708 let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
709 let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
710
711 let canonical = revocation_canonical_persona(&grant_ref, revoked_at_ms);
712 verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
713
714 Some(VerifiedRevocation {
715 grant_ref,
716 revoked_at_ms,
717 registered_public_key: expected_persona_pubkey.to_vec(),
718 })
719}
720
721#[must_use]
727pub fn suspension_canonical(grant_ref: &str, reason: &str, suspended_at_ms: u64) -> Vec<u8> {
728 canonical_bytes(&suspension_fields(grant_ref, reason, suspended_at_ms))
729}
730
731const fn suspension_fields<'a>(
734 grant_ref: &'a str,
735 reason: &'a str,
736 suspended_at_ms: u64,
737) -> SuspensionCanonical<'a> {
738 SuspensionCanonical {
739 kind: SUSPENSION_KIND,
740 grant_ref,
741 reason,
742 suspended_at_ms,
743 }
744}
745
746#[must_use]
754pub fn suspension_payload(
755 grant_ref: &str,
756 reason: &str,
757 suspended_at_ms: u64,
758 signer: &Signer,
759) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
760 Envelope::seal(
761 suspension_fields(grant_ref, reason, suspended_at_ms),
762 signer,
763 )
764}
765
766#[derive(Debug, Clone, PartialEq, Eq)]
768pub struct VerifiedSuspension {
769 pub grant_ref: String,
771 pub reason: String,
773 pub suspended_at_ms: u64,
775 pub signer_public_key: Vec<u8>,
777}
778
779#[must_use]
801pub fn verify_suspension(
802 payload: &[u8],
803 expected_platform_key: &[u8],
804) -> Option<VerifiedSuspension> {
805 let v: Value = serde_json::from_slice(payload).ok()?;
806 if v.get("kind")?.as_str()? != SUSPENSION_KIND {
807 return None;
808 }
809 let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
810 let reason = v.get("reason")?.as_str()?.to_owned();
811 let suspended_at_ms = v.get("suspended_at_ms")?.as_u64()?;
812 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
813 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
814 if pk != expected_platform_key {
815 return None;
816 }
817 let canonical = suspension_canonical(&grant_ref, &reason, suspended_at_ms);
818 if verify(&pk, &canonical, &sig) {
819 Some(VerifiedSuspension {
820 grant_ref,
821 reason,
822 suspended_at_ms,
823 signer_public_key: pk,
824 })
825 } else {
826 None
827 }
828}
829
830#[must_use]
838pub fn live_grant<'a>(
839 grants: &'a [VerifiedGrant],
840 revocations: &[VerifiedRevocation],
841 suspensions: &[VerifiedSuspension],
842) -> Option<&'a VerifiedGrant> {
843 grants.iter().find(|g| {
844 !revocations.iter().any(|r| r.grant_ref == g.grant_ref)
845 && !suspensions.iter().any(|s| s.grant_ref == g.grant_ref)
846 })
847}
848
849fn b64(bytes: &[u8]) -> String {
850 URL_SAFE_NO_PAD.encode(bytes)
851}
852
853fn b64_decode(s: &str) -> Option<Vec<u8>> {
854 URL_SAFE_NO_PAD.decode(s).ok()
855}
856
857#[cfg(test)]
858mod tests {
859 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
860
861 use super::*;
862 use polyc_passkey::softkey::SoftPasskey;
863
864 const RP_ID: &str = "enroll.polychrome.test";
865 const ORIGIN: &str = "https://enroll.polychrome.test";
866
867 fn caps() -> Vec<String> {
868 vec![
869 "arbitrary-egress".to_owned(),
870 "fixed-connector-read".to_owned(),
871 ]
872 }
873
874 fn mint_grant(key: &SoftPasskey) -> Vec<u8> {
877 let covered = caps();
878 let coverage = GrantCoverage {
879 destination: "slack:T1:C42",
880 slot_schema_hash: "sha256:template-abc",
881 covered_capabilities: &covered,
882 };
883 let canonical = grant_canonical(
884 "persona-9",
885 "standup",
886 "post_summary",
887 &coverage,
888 "enr-1",
889 1_700_000_000_000,
890 );
891 let challenge = grant_challenge(&canonical);
892 let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
893 let proof = WebAuthnProof {
894 credential_id: &signed.credential_id,
895 authenticator_data: &signed.authenticator_data,
896 client_data_json: &signed.client_data_json,
897 signature: &signed.signature,
898 };
899 signed_grant_payload(
900 "persona-9",
901 "standup",
902 "post_summary",
903 &coverage,
904 "enr-1",
905 1_700_000_000_000,
906 &proof,
907 )
908 }
909
910 #[test]
911 fn grant_round_trips_and_carries_coverage() {
912 let key = SoftPasskey::from_seed(1);
913 let payload = mint_grant(&key);
914 let g = verify_grant(&payload, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
915 assert_eq!(g.principal, "persona-9");
916 assert_eq!(g.routine, "standup");
917 assert_eq!(g.tool, "post_summary");
918 assert_eq!(g.destination, "slack:T1:C42");
919 assert_eq!(g.slot_schema_hash, "sha256:template-abc");
920 assert_eq!(g.covered_capabilities, caps());
921 assert_eq!(g.enrollment_id, "enr-1");
922 assert_eq!(g.grant_ref, grant_ref(&payload));
923 }
924
925 #[test]
926 fn grant_signed_by_a_different_passkey_is_dropped() {
927 let signer = SoftPasskey::from_seed(1);
928 let attacker = SoftPasskey::from_seed(2);
929 let payload = mint_grant(&signer);
930 assert!(verify_grant(&payload, &attacker.public_key_sec1(), RP_ID, ORIGIN).is_none());
932 assert!(verify_grant(&payload, &signer.public_key_sec1(), RP_ID, ORIGIN).is_some());
934 }
935
936 #[test]
937 fn every_canonical_field_including_kind_is_tamper_evident() {
938 let key = SoftPasskey::from_seed(3);
939 let payload = mint_grant(&key);
940 let pk = key.public_key_sec1();
941 for (field, val) in [
951 ("kind", serde_json::json!(REVOCATION_KIND)),
952 ("principal", serde_json::json!("persona-EVIL")),
953 ("routine", serde_json::json!("other-routine")),
954 ("tool", serde_json::json!("exfiltrate")),
955 ("destination", serde_json::json!("slack:T1:CEVIL")),
956 ("slot_schema_hash", serde_json::json!("sha256:tampered")),
957 (
958 "covered_capabilities",
959 serde_json::json!(["arbitrary-egress", "mutate-external"]),
960 ),
961 ("enrollment_id", serde_json::json!("enr-2")),
962 ("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
963 ] {
964 let mut v: Value = serde_json::from_slice(&payload).unwrap();
965 v[field] = val;
966 assert!(
967 verify_grant(v.to_string().as_bytes(), &pk, RP_ID, ORIGIN).is_none(),
968 "tampered {field} must fail verification"
969 );
970 }
971 assert!(verify_grant(&payload, &pk, RP_ID, ORIGIN).is_some());
973 }
974
975 #[test]
976 fn grant_ref_is_sensitive_to_the_full_payload() {
977 let key = SoftPasskey::from_seed(4);
978 let a = mint_grant(&key);
979 assert_eq!(grant_ref(&a), grant_ref(&a));
980 let b = mint_grant(&SoftPasskey::from_seed(5));
983 assert_ne!(grant_ref(&a), grant_ref(&b));
984 }
985
986 fn mint_revocation(key: &SoftPasskey, grant_ref_hex: &str, revoked_at_ms: u64) -> Vec<u8> {
987 let canonical = revocation_canonical(grant_ref_hex, revoked_at_ms);
988 let challenge = grant_challenge(&canonical);
989 let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
990 let proof = WebAuthnProof {
991 credential_id: &signed.credential_id,
992 authenticator_data: &signed.authenticator_data,
993 client_data_json: &signed.client_data_json,
994 signature: &signed.signature,
995 };
996 signed_revocation_payload(grant_ref_hex, revoked_at_ms, &proof)
997 }
998
999 #[test]
1000 fn revocation_round_trips_and_binds_the_grant_ref() {
1001 let key = SoftPasskey::from_seed(6);
1002 let payload = mint_grant(&key);
1003 let gref = grant_ref(&payload);
1004 let rev = mint_revocation(&key, &gref, 1_700_000_100_000);
1005 let v = verify_revocation(&rev, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
1006 assert_eq!(v.grant_ref, gref);
1007 assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
1008 assert!(
1010 verify_revocation(
1011 &rev,
1012 &SoftPasskey::from_seed(7).public_key_sec1(),
1013 RP_ID,
1014 ORIGIN
1015 )
1016 .is_none()
1017 );
1018 }
1019
1020 #[test]
1021 fn revocation_tampered_grant_ref_fails() {
1022 let key = SoftPasskey::from_seed(6);
1023 let rev = mint_revocation(&key, &"0".repeat(64), 1);
1024 let mut v: Value = serde_json::from_slice(&rev).unwrap();
1025 v["grant_ref"] = serde_json::json!("f".repeat(64));
1026 assert!(
1027 verify_revocation(
1028 v.to_string().as_bytes(),
1029 &key.public_key_sec1(),
1030 RP_ID,
1031 ORIGIN
1032 )
1033 .is_none()
1034 );
1035 }
1036
1037 #[test]
1038 fn suspension_round_trips_and_is_tamper_evident() {
1039 let signer = Signer::from_seed(1);
1040 let platform_key = signer.public_key_bytes();
1041 let (payload, _sig, _pk) = suspension_payload(
1042 "deadbeef",
1043 "misbehaving routine",
1044 1_700_000_200_000,
1045 &signer,
1046 );
1047 let v = verify_suspension(&payload, &platform_key).expect("verifies");
1048 assert_eq!(v.grant_ref, "deadbeef");
1049 assert_eq!(v.reason, "misbehaving routine");
1050 assert_eq!(v.suspended_at_ms, 1_700_000_200_000);
1051 for (field, val) in [
1052 ("grant_ref", serde_json::json!("cafe")),
1053 ("reason", serde_json::json!("something else")),
1054 ("suspended_at_ms", serde_json::json!(0u64)),
1055 ] {
1056 let mut t: Value = serde_json::from_slice(&payload).unwrap();
1057 t[field] = val;
1058 assert!(
1059 verify_suspension(t.to_string().as_bytes(), &platform_key).is_none(),
1060 "tampered {field} must fail"
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn suspension_signed_by_a_key_other_than_the_expected_platform_key_is_dropped() {
1067 let forger = Signer::from_seed(99);
1076 let real_platform = Signer::from_seed(1);
1077 let (payload, _sig, _pk) =
1078 suspension_payload("deadbeef", "misbehaving routine", 1, &forger);
1079 assert!(verify_suspension(&payload, &real_platform.public_key_bytes()).is_none());
1080 }
1081
1082 #[test]
1083 fn suspension_signed_by_the_expected_platform_key_round_trips() {
1084 let platform = Signer::from_seed(1);
1085 let (payload, _sig, _pk) =
1086 suspension_payload("deadbeef", "misbehaving routine", 1, &platform);
1087 assert!(verify_suspension(&payload, &platform.public_key_bytes()).is_some());
1088 }
1089
1090 #[test]
1091 fn live_grant_ignores_a_wrong_key_suspension_and_leaves_the_grant_live() {
1092 let key = SoftPasskey::from_seed(20);
1101 let payload = mint_grant(&key);
1102 let pk = key.public_key_sec1();
1103 let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
1104 let grants = vec![grant.clone()];
1105
1106 let forger = Signer::from_seed(99);
1107 let real_platform = Signer::from_seed(1);
1108 let (susp_payload, _s, _p) =
1109 suspension_payload(&grant.grant_ref, "kill switch", 1, &forger);
1110 assert!(verify_suspension(&susp_payload, &real_platform.public_key_bytes()).is_none());
1112 assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1114 }
1115
1116 #[test]
1117 fn revocation_and_suspension_cannot_be_confused() {
1118 let signer = Signer::from_seed(1);
1121 let (susp, _s, _p) = suspension_payload("deadbeef", "x", 1, &signer);
1122 assert!(
1123 verify_revocation(
1124 &susp,
1125 &SoftPasskey::from_seed(1).public_key_sec1(),
1126 RP_ID,
1127 ORIGIN
1128 )
1129 .is_none()
1130 );
1131 let grant = mint_grant(&SoftPasskey::from_seed(1));
1132 assert!(verify_suspension(&grant, &signer.public_key_bytes()).is_none());
1133 }
1134
1135 #[test]
1136 fn live_grant_folds_revocations_and_suspensions() {
1137 let key = SoftPasskey::from_seed(8);
1138 let payload = mint_grant(&key);
1139 let pk = key.public_key_sec1();
1140 let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
1141 let grants = vec![grant.clone()];
1142
1143 assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1145
1146 let rev = verify_revocation(
1148 &mint_revocation(&key, &grant.grant_ref, 1),
1149 &pk,
1150 RP_ID,
1151 ORIGIN,
1152 )
1153 .unwrap();
1154 assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
1155
1156 let platform = Signer::from_seed(1);
1158 let (susp_payload, _s, _p) =
1159 suspension_payload(&grant.grant_ref, "kill switch", 1, &platform);
1160 let susp = verify_suspension(&susp_payload, &platform.public_key_bytes()).unwrap();
1161 assert!(live_grant(&grants, &[], std::slice::from_ref(&susp)).is_none());
1162
1163 let other = verify_revocation(
1165 &mint_revocation(&key, &"0".repeat(64), 1),
1166 &pk,
1167 RP_ID,
1168 ORIGIN,
1169 )
1170 .unwrap();
1171 assert_eq!(
1172 live_grant(&grants, std::slice::from_ref(&other), &[]),
1173 Some(&grants[0])
1174 );
1175 }
1176
1177 #[test]
1178 fn garbage_payloads_return_none_not_panic() {
1179 let pk = SoftPasskey::from_seed(1).public_key_sec1();
1180 assert!(verify_grant(b"not json", &pk, RP_ID, ORIGIN).is_none());
1181 assert!(verify_revocation(b"{}", &pk, RP_ID, ORIGIN).is_none());
1182 assert!(verify_suspension(b"[]", &Signer::from_seed(1).public_key_bytes()).is_none());
1183 }
1184
1185 fn persona_keypair(seed: &[u8]) -> polyc_passkey::derive::SigningKeypair {
1188 polyc_passkey::derive::derive_signing_keypair(seed)
1189 }
1190
1191 fn mint_persona_grant(keypair: &polyc_passkey::derive::SigningKeypair) -> Vec<u8> {
1195 let covered = caps();
1196 let coverage = GrantCoverage {
1197 destination: "slack:T1:C42",
1198 slot_schema_hash: "sha256:template-abc",
1199 covered_capabilities: &covered,
1200 };
1201 let canonical = grant_canonical_persona(
1202 "persona-9",
1203 "standup",
1204 "post_summary",
1205 &coverage,
1206 "enr-1",
1207 1_700_000_000_000,
1208 );
1209 let signature = keypair.sign(&canonical);
1210 let public_key = keypair.public_key();
1211 signed_grant_payload_persona(
1212 "persona-9",
1213 "standup",
1214 "post_summary",
1215 &coverage,
1216 "enr-1",
1217 1_700_000_000_000,
1218 &PersonaProof {
1219 signing_public_key: &public_key,
1220 signature: &signature,
1221 },
1222 )
1223 }
1224
1225 #[test]
1226 fn persona_grant_round_trips_and_carries_coverage() {
1227 let keypair = persona_keypair(b"persona-seed-1-------------------");
1228 let payload = mint_persona_grant(&keypair);
1229 let g = verify_persona_signed_grant(&payload, &keypair.public_key()).expect("verifies");
1230 assert_eq!(g.principal, "persona-9");
1231 assert_eq!(g.routine, "standup");
1232 assert_eq!(g.tool, "post_summary");
1233 assert_eq!(g.destination, "slack:T1:C42");
1234 assert_eq!(g.slot_schema_hash, "sha256:template-abc");
1235 assert_eq!(g.covered_capabilities, caps());
1236 assert_eq!(g.enrollment_id, "enr-1");
1237 assert_eq!(g.grant_ref, grant_ref(&payload));
1238 assert_eq!(g.registered_public_key, keypair.public_key());
1239 }
1240
1241 #[test]
1242 fn persona_grant_signed_by_a_different_key_is_dropped() {
1243 let signer = persona_keypair(b"persona-seed-2-------------------");
1244 let attacker = persona_keypair(b"persona-seed-3-------------------");
1245 let payload = mint_persona_grant(&signer);
1246 assert!(verify_persona_signed_grant(&payload, &attacker.public_key()).is_none());
1248 assert!(verify_persona_signed_grant(&payload, &signer.public_key()).is_some());
1250 }
1251
1252 #[test]
1253 fn persona_grant_every_canonical_field_is_tamper_evident() {
1254 let keypair = persona_keypair(b"persona-seed-4-------------------");
1255 let payload = mint_persona_grant(&keypair);
1256 let pk = keypair.public_key();
1257 for (field, val) in [
1258 ("kind", serde_json::json!(REVOCATION_PERSONA_KIND)),
1259 ("principal", serde_json::json!("persona-EVIL")),
1260 ("routine", serde_json::json!("other-routine")),
1261 ("tool", serde_json::json!("exfiltrate")),
1262 ("destination", serde_json::json!("slack:T1:CEVIL")),
1263 ("slot_schema_hash", serde_json::json!("sha256:tampered")),
1264 (
1265 "covered_capabilities",
1266 serde_json::json!(["arbitrary-egress", "mutate-external"]),
1267 ),
1268 ("enrollment_id", serde_json::json!("enr-2")),
1269 ("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
1270 ] {
1271 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1272 v[field] = val;
1273 assert!(
1274 verify_persona_signed_grant(v.to_string().as_bytes(), &pk).is_none(),
1275 "tampered {field} must fail verification"
1276 );
1277 }
1278 assert!(verify_persona_signed_grant(&payload, &pk).is_some());
1279 }
1280
1281 #[test]
1282 fn persona_grant_and_webauthn_grant_never_verify_as_each_other() {
1283 let keypair = persona_keypair(b"persona-seed-5-------------------");
1290 let persona_payload = mint_persona_grant(&keypair);
1291 assert!(
1292 verify_grant(
1293 &persona_payload,
1294 &SoftPasskey::from_seed(1).public_key_sec1(),
1295 RP_ID,
1296 ORIGIN
1297 )
1298 .is_none()
1299 );
1300
1301 let webauthn_payload = mint_grant(&SoftPasskey::from_seed(1));
1302 assert!(verify_persona_signed_grant(&webauthn_payload, &keypair.public_key()).is_none());
1303 }
1304
1305 #[test]
1306 fn persona_revocation_round_trips_and_binds_the_grant_ref() {
1307 let keypair = persona_keypair(b"persona-seed-6-------------------");
1308 let payload = mint_persona_grant(&keypair);
1309 let gref = grant_ref(&payload);
1310 let canonical = revocation_canonical_persona(&gref, 1_700_000_100_000);
1311 let signature = keypair.sign(&canonical);
1312 let public_key = keypair.public_key();
1313 let rev = signed_revocation_payload_persona(
1314 &gref,
1315 1_700_000_100_000,
1316 &PersonaProof {
1317 signing_public_key: &public_key,
1318 signature: &signature,
1319 },
1320 );
1321 let v = verify_persona_signed_revocation(&rev, &keypair.public_key()).expect("verifies");
1322 assert_eq!(v.grant_ref, gref);
1323 assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
1324 let other = persona_keypair(b"persona-seed-7-------------------");
1326 assert!(verify_persona_signed_revocation(&rev, &other.public_key()).is_none());
1327 }
1328
1329 #[test]
1330 fn persona_grant_and_revocation_can_fold_through_live_grant() {
1331 let keypair = persona_keypair(b"persona-seed-8-------------------");
1336 let payload = mint_persona_grant(&keypair);
1337 let grant = verify_persona_signed_grant(&payload, &keypair.public_key()).unwrap();
1338 let grants = vec![grant.clone()];
1339 assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1340
1341 let canonical = revocation_canonical_persona(&grant.grant_ref, 1);
1342 let signature = keypair.sign(&canonical);
1343 let public_key = keypair.public_key();
1344 let rev_payload = signed_revocation_payload_persona(
1345 &grant.grant_ref,
1346 1,
1347 &PersonaProof {
1348 signing_public_key: &public_key,
1349 signature: &signature,
1350 },
1351 );
1352 let rev = verify_persona_signed_revocation(&rev_payload, &keypair.public_key()).unwrap();
1353 assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
1354 }
1355
1356 #[test]
1357 fn persona_grant_garbage_payloads_return_none_not_panic() {
1358 let pk = persona_keypair(b"persona-seed-9-------------------").public_key();
1359 assert!(verify_persona_signed_grant(b"not json", &pk).is_none());
1360 assert!(verify_persona_signed_revocation(b"{}", &pk).is_none());
1361 }
1362}
1363
1364#[cfg(test)]
1365mod canonical_freeze {
1366 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1391
1392 use super::*;
1393
1394 fn frozen(label: &str, got: &[u8], want: &str) {
1396 assert_eq!(
1397 String::from_utf8(got.to_vec()).unwrap(),
1398 want,
1399 "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
1400 );
1401 }
1402
1403 const DESTINATION: &str = r#"{"channel":"C1","workspace":"T1"}"#;
1404 const GRANT_REF: &str = "cafe0123";
1405 const CREATED_AT_MS: u64 = 1_700_000_000_000;
1406 const REVOKED_AT_MS: u64 = 1_700_000_100_000;
1407 const SUSPENDED_AT_MS: u64 = 1_700_000_200_000;
1408 const REASON: &str = "policy change";
1409
1410 fn caps() -> Vec<String> {
1411 vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()]
1412 }
1413
1414 #[test]
1415 fn grant_canonicals_are_frozen() {
1416 let caps = caps();
1417 let coverage = GrantCoverage {
1418 destination: DESTINATION,
1419 slot_schema_hash: "sha256:abc",
1420 covered_capabilities: &caps,
1421 };
1422 frozen(
1423 "grant_canonical",
1424 &grant_canonical(
1425 "persona-1",
1426 "routine-1",
1427 "paid_fetch",
1428 &coverage,
1429 "enr-1",
1430 CREATED_AT_MS,
1431 ),
1432 GRANT_CANONICAL,
1433 );
1434 frozen(
1435 "grant_canonical_persona",
1436 &grant_canonical_persona(
1437 "persona-1",
1438 "routine-1",
1439 "paid_fetch",
1440 &coverage,
1441 "enr-1",
1442 CREATED_AT_MS,
1443 ),
1444 GRANT_PERSONA_CANONICAL,
1445 );
1446 }
1447
1448 #[test]
1449 fn revocation_canonicals_are_frozen() {
1450 frozen(
1451 "revocation_canonical",
1452 &revocation_canonical(GRANT_REF, REVOKED_AT_MS),
1453 REVOCATION_CANONICAL,
1454 );
1455 frozen(
1456 "revocation_canonical_persona",
1457 &revocation_canonical_persona(GRANT_REF, REVOKED_AT_MS),
1458 REVOCATION_PERSONA_CANONICAL,
1459 );
1460 }
1461
1462 #[test]
1463 fn suspension_canonical_and_payload_are_frozen() {
1464 frozen(
1465 "suspension_canonical",
1466 &suspension_canonical(GRANT_REF, REASON, SUSPENDED_AT_MS),
1467 SUSPENSION_CANONICAL,
1468 );
1469 let (full, sig, _) =
1470 suspension_payload(GRANT_REF, REASON, SUSPENDED_AT_MS, &Signer::from_seed(99));
1471 frozen("suspension_payload", &full, SUSPENSION_PAYLOAD);
1472 assert_eq!(crate::hex::lower(&sig), SUSPENSION_SIG);
1473 }
1474
1475 const GRANT_CANONICAL: &str = r#"{"kind":"grant.routine.v1","principal":"persona-1","routine":"routine-1","tool":"paid_fetch","destination":"{\"channel\":\"C1\",\"workspace\":\"T1\"}","slot_schema_hash":"sha256:abc","covered_capabilities":["arbitrary-egress","mutate-external"],"enrollment_id":"enr-1","created_at_ms":1700000000000}"#;
1476 const GRANT_PERSONA_CANONICAL: &str = r#"{"kind":"grant.routine.persona.v1","principal":"persona-1","routine":"routine-1","tool":"paid_fetch","destination":"{\"channel\":\"C1\",\"workspace\":\"T1\"}","slot_schema_hash":"sha256:abc","covered_capabilities":["arbitrary-egress","mutate-external"],"enrollment_id":"enr-1","created_at_ms":1700000000000}"#;
1477 const REVOCATION_CANONICAL: &str =
1478 r#"{"kind":"grant.revocation.v1","grant_ref":"cafe0123","revoked_at_ms":1700000100000}"#;
1479 const REVOCATION_PERSONA_CANONICAL: &str = r#"{"kind":"grant.revocation.persona.v1","grant_ref":"cafe0123","revoked_at_ms":1700000100000}"#;
1480 const SUSPENSION_CANONICAL: &str = r#"{"kind":"grant.suspension.v1","grant_ref":"cafe0123","reason":"policy change","suspended_at_ms":1700000200000}"#;
1481 const SUSPENSION_PAYLOAD: &str = r#"{"kind":"grant.suspension.v1","grant_ref":"cafe0123","reason":"policy change","suspended_at_ms":1700000200000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"dd7c046496ba66d2fc531bbbdbdc926a3f400a45a399a2c7668a2a59253d067eecfd6447de4b2842d21331db5aa8fc6154776094cc694399b507c0684459ea01"}"#;
1482 const SUSPENSION_SIG: &str = "dd7c046496ba66d2fc531bbbdbdc926a3f400a45a399a2c7668a2a59253d067eecfd6447de4b2842d21331db5aa8fc6154776094cc694399b507c0684459ea01";
1483}