1use serde_json::Value;
33use sha2::{Digest, Sha256};
34
35use base64::Engine as _;
36use base64::engine::general_purpose::URL_SAFE_NO_PAD;
37
38use crate::{Signer, verify};
39use polyc_passkey::derive::verify_derived_signature;
40use polyc_passkey::{Assertion, WebAuthnProof, challenge_b64, verify_assertion};
41
42pub const GRANT_KIND: &str = "grant.routine.v1";
44pub const REVOCATION_KIND: &str = "grant.revocation.v1";
46pub const SUSPENSION_KIND: &str = "grant.suspension.v1";
48pub const GRANT_PERSONA_KIND: &str = "grant.routine.persona.v1";
56pub const REVOCATION_PERSONA_KIND: &str = "grant.revocation.persona.v1";
60
61#[derive(Debug, Clone, Copy)]
68pub struct PersonaProof<'a> {
69 pub signing_public_key: &'a [u8],
71 pub signature: &'a [u8],
74}
75
76#[derive(Debug, Clone, Copy)]
86pub struct GrantCoverage<'a> {
87 pub destination: &'a str,
98 pub slot_schema_hash: &'a str,
100 pub covered_capabilities: &'a [String],
102}
103
104#[must_use]
112pub fn grant_canonical(
113 principal: &str,
114 routine: &str,
115 tool: &str,
116 coverage: &GrantCoverage<'_>,
117 enrollment_id: &str,
118 created_at_ms: u64,
119) -> Vec<u8> {
120 serde_json::json!({
121 "kind": GRANT_KIND,
122 "principal": principal,
123 "routine": routine,
124 "tool": tool,
125 "destination": coverage.destination,
126 "slot_schema_hash": coverage.slot_schema_hash,
127 "covered_capabilities": coverage.covered_capabilities,
128 "enrollment_id": enrollment_id,
129 "created_at_ms": created_at_ms,
130 })
131 .to_string()
132 .into_bytes()
133}
134
135#[must_use]
143pub fn grant_challenge(canonical: &[u8]) -> String {
144 challenge_b64(canonical)
145}
146
147#[must_use]
157pub fn grant_ref(signed_grant_payload: &[u8]) -> String {
158 crate::hex::lower(&Sha256::digest(signed_grant_payload))
159}
160
161#[must_use]
170pub fn signed_grant_payload(
171 principal: &str,
172 routine: &str,
173 tool: &str,
174 coverage: &GrantCoverage<'_>,
175 enrollment_id: &str,
176 created_at_ms: u64,
177 proof: &WebAuthnProof<'_>,
178) -> Vec<u8> {
179 let canonical = grant_canonical(
180 principal,
181 routine,
182 tool,
183 coverage,
184 enrollment_id,
185 created_at_ms,
186 );
187 attach_proof(&canonical, proof)
188}
189
190fn attach_proof(canonical: &[u8], proof: &WebAuthnProof<'_>) -> Vec<u8> {
211 let mut map: serde_json::Map<String, Value> =
212 serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
213 map.insert(
214 "credential_id".to_owned(),
215 Value::String(b64(proof.credential_id)),
216 );
217 map.insert(
218 "authenticator_data".to_owned(),
219 Value::String(b64(proof.authenticator_data)),
220 );
221 map.insert(
222 "client_data_json".to_owned(),
223 Value::String(b64(proof.client_data_json)),
224 );
225 map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
226 Value::Object(map).to_string().into_bytes()
227}
228
229fn verify_passkey_proof(
241 v: &Value,
242 canonical: &[u8],
243 registered_pubkey_sec1: &[u8],
244 rp_id: &str,
245 origin: &str,
246) -> Option<()> {
247 let authenticator_data = b64_decode(v.get("authenticator_data")?.as_str()?)?;
248 let client_data_json = b64_decode(v.get("client_data_json")?.as_str()?)?;
249 let signature = b64_decode(v.get("signature")?.as_str()?)?;
250 let assertion = Assertion {
251 authenticator_data: &authenticator_data,
252 client_data_json: &client_data_json,
253 signature: &signature,
254 };
255 verify_assertion(
256 registered_pubkey_sec1,
257 &assertion,
258 &grant_challenge(canonical),
259 rp_id,
260 origin,
261 )
262 .ok()
263 .map(|_checks| ())
264}
265
266fn attach_persona_proof(canonical: &[u8], proof: &PersonaProof<'_>) -> Vec<u8> {
276 let mut map: serde_json::Map<String, Value> =
277 serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
278 map.insert(
279 "signing_public_key".to_owned(),
280 Value::String(b64(proof.signing_public_key)),
281 );
282 map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
283 Value::Object(map).to_string().into_bytes()
284}
285
286fn verify_persona_proof(v: &Value, canonical: &[u8], expected_persona_pubkey: &[u8]) -> Option<()> {
301 let signing_public_key = b64_decode(v.get("signing_public_key")?.as_str()?)?;
302 let signature = b64_decode(v.get("signature")?.as_str()?)?;
303 if signing_public_key != expected_persona_pubkey {
304 return None;
305 }
306 if verify_derived_signature(&signing_public_key, canonical, &signature) {
307 Some(())
308 } else {
309 None
310 }
311}
312
313#[must_use]
319pub fn grant_canonical_persona(
320 principal: &str,
321 routine: &str,
322 tool: &str,
323 coverage: &GrantCoverage<'_>,
324 enrollment_id: &str,
325 created_at_ms: u64,
326) -> Vec<u8> {
327 serde_json::json!({
328 "kind": GRANT_PERSONA_KIND,
329 "principal": principal,
330 "routine": routine,
331 "tool": tool,
332 "destination": coverage.destination,
333 "slot_schema_hash": coverage.slot_schema_hash,
334 "covered_capabilities": coverage.covered_capabilities,
335 "enrollment_id": enrollment_id,
336 "created_at_ms": created_at_ms,
337 })
338 .to_string()
339 .into_bytes()
340}
341
342#[must_use]
351pub fn signed_grant_payload_persona(
352 principal: &str,
353 routine: &str,
354 tool: &str,
355 coverage: &GrantCoverage<'_>,
356 enrollment_id: &str,
357 created_at_ms: u64,
358 proof: &PersonaProof<'_>,
359) -> Vec<u8> {
360 let canonical = grant_canonical_persona(
361 principal,
362 routine,
363 tool,
364 coverage,
365 enrollment_id,
366 created_at_ms,
367 );
368 attach_persona_proof(&canonical, proof)
369}
370
371#[must_use]
384pub fn verify_persona_signed_grant(
385 payload: &[u8],
386 expected_persona_pubkey: &[u8],
387) -> Option<VerifiedGrant> {
388 let v: Value = serde_json::from_slice(payload).ok()?;
389 if v.get("kind")?.as_str()? != GRANT_PERSONA_KIND {
390 return None;
391 }
392 let principal = v.get("principal")?.as_str()?.to_owned();
393 let routine = v.get("routine")?.as_str()?.to_owned();
394 let tool = v.get("tool")?.as_str()?.to_owned();
395 let destination = v.get("destination")?.as_str()?.to_owned();
396 let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
397 let covered_capabilities: Vec<String> = v
398 .get("covered_capabilities")?
399 .as_array()?
400 .iter()
401 .map(|c| c.as_str().map(str::to_owned))
402 .collect::<Option<Vec<_>>>()?;
403 let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
404 let created_at_ms = v.get("created_at_ms")?.as_u64()?;
405
406 let coverage = GrantCoverage {
407 destination: &destination,
408 slot_schema_hash: &slot_schema_hash,
409 covered_capabilities: &covered_capabilities,
410 };
411 let canonical = grant_canonical_persona(
412 &principal,
413 &routine,
414 &tool,
415 &coverage,
416 &enrollment_id,
417 created_at_ms,
418 );
419 verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
420
421 Some(VerifiedGrant {
422 principal,
423 routine,
424 tool,
425 destination,
426 slot_schema_hash,
427 covered_capabilities,
428 enrollment_id,
429 created_at_ms,
430 grant_ref: grant_ref(payload),
431 registered_public_key: expected_persona_pubkey.to_vec(),
432 })
433}
434
435#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct VerifiedGrant {
438 pub principal: String,
442 pub routine: String,
444 pub tool: String,
446 pub destination: String,
448 pub slot_schema_hash: String,
450 pub covered_capabilities: Vec<String>,
452 pub enrollment_id: String,
454 pub created_at_ms: u64,
456 pub grant_ref: String,
459 pub registered_public_key: Vec<u8>,
461}
462
463#[must_use]
473pub fn verify_grant(
474 payload: &[u8],
475 registered_pubkey_sec1: &[u8],
476 rp_id: &str,
477 origin: &str,
478) -> Option<VerifiedGrant> {
479 let v: Value = serde_json::from_slice(payload).ok()?;
480 if v.get("kind")?.as_str()? != GRANT_KIND {
481 return None;
482 }
483 let principal = v.get("principal")?.as_str()?.to_owned();
484 let routine = v.get("routine")?.as_str()?.to_owned();
485 let tool = v.get("tool")?.as_str()?.to_owned();
486 let destination = v.get("destination")?.as_str()?.to_owned();
487 let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
488 let covered_capabilities: Vec<String> = v
489 .get("covered_capabilities")?
490 .as_array()?
491 .iter()
492 .map(|c| c.as_str().map(str::to_owned))
493 .collect::<Option<Vec<_>>>()?;
494 let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
495 let created_at_ms = v.get("created_at_ms")?.as_u64()?;
496
497 let coverage = GrantCoverage {
498 destination: &destination,
499 slot_schema_hash: &slot_schema_hash,
500 covered_capabilities: &covered_capabilities,
501 };
502 let canonical = grant_canonical(
503 &principal,
504 &routine,
505 &tool,
506 &coverage,
507 &enrollment_id,
508 created_at_ms,
509 );
510 verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
511
512 Some(VerifiedGrant {
513 principal,
514 routine,
515 tool,
516 destination,
517 slot_schema_hash,
518 covered_capabilities,
519 enrollment_id,
520 created_at_ms,
521 grant_ref: grant_ref(payload),
522 registered_public_key: registered_pubkey_sec1.to_vec(),
523 })
524}
525
526#[must_use]
532pub fn revocation_canonical(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
533 serde_json::json!({
534 "kind": REVOCATION_KIND,
535 "grant_ref": grant_ref,
536 "revoked_at_ms": revoked_at_ms,
537 })
538 .to_string()
539 .into_bytes()
540}
541
542#[must_use]
548pub fn signed_revocation_payload(
549 grant_ref: &str,
550 revoked_at_ms: u64,
551 proof: &WebAuthnProof<'_>,
552) -> Vec<u8> {
553 let canonical = revocation_canonical(grant_ref, revoked_at_ms);
554 attach_proof(&canonical, proof)
555}
556
557#[derive(Debug, Clone, PartialEq, Eq)]
559pub struct VerifiedRevocation {
560 pub grant_ref: String,
562 pub revoked_at_ms: u64,
564 pub registered_public_key: Vec<u8>,
566}
567
568#[must_use]
576pub fn verify_revocation(
577 payload: &[u8],
578 registered_pubkey_sec1: &[u8],
579 rp_id: &str,
580 origin: &str,
581) -> Option<VerifiedRevocation> {
582 let v: Value = serde_json::from_slice(payload).ok()?;
583 if v.get("kind")?.as_str()? != REVOCATION_KIND {
584 return None;
585 }
586 let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
587 let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
588
589 let canonical = revocation_canonical(&grant_ref, revoked_at_ms);
590 verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
591
592 Some(VerifiedRevocation {
593 grant_ref,
594 revoked_at_ms,
595 registered_public_key: registered_pubkey_sec1.to_vec(),
596 })
597}
598
599#[must_use]
604pub fn revocation_canonical_persona(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
605 serde_json::json!({
606 "kind": REVOCATION_PERSONA_KIND,
607 "grant_ref": grant_ref,
608 "revoked_at_ms": revoked_at_ms,
609 })
610 .to_string()
611 .into_bytes()
612}
613
614#[must_use]
619pub fn signed_revocation_payload_persona(
620 grant_ref: &str,
621 revoked_at_ms: u64,
622 proof: &PersonaProof<'_>,
623) -> Vec<u8> {
624 let canonical = revocation_canonical_persona(grant_ref, revoked_at_ms);
625 attach_persona_proof(&canonical, proof)
626}
627
628#[must_use]
635pub fn verify_persona_signed_revocation(
636 payload: &[u8],
637 expected_persona_pubkey: &[u8],
638) -> Option<VerifiedRevocation> {
639 let v: Value = serde_json::from_slice(payload).ok()?;
640 if v.get("kind")?.as_str()? != REVOCATION_PERSONA_KIND {
641 return None;
642 }
643 let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
644 let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
645
646 let canonical = revocation_canonical_persona(&grant_ref, revoked_at_ms);
647 verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
648
649 Some(VerifiedRevocation {
650 grant_ref,
651 revoked_at_ms,
652 registered_public_key: expected_persona_pubkey.to_vec(),
653 })
654}
655
656#[must_use]
662pub fn suspension_canonical(grant_ref: &str, reason: &str, suspended_at_ms: u64) -> Vec<u8> {
663 serde_json::json!({
664 "kind": SUSPENSION_KIND,
665 "grant_ref": grant_ref,
666 "reason": reason,
667 "suspended_at_ms": suspended_at_ms,
668 })
669 .to_string()
670 .into_bytes()
671}
672
673#[must_use]
681pub fn suspension_payload(
682 grant_ref: &str,
683 reason: &str,
684 suspended_at_ms: u64,
685 signer: &Signer,
686) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
687 let canonical = suspension_canonical(grant_ref, reason, suspended_at_ms);
688 let signature = signer.sign(&canonical);
689 let pk = signer.public_key_bytes();
690 let full = serde_json::json!({
691 "kind": SUSPENSION_KIND,
692 "grant_ref": grant_ref,
693 "reason": reason,
694 "suspended_at_ms": suspended_at_ms,
695 "signed_by": crate::hex::lower(&pk),
696 "signature_hex": crate::hex::lower(&signature),
697 });
698 (full.to_string().into_bytes(), signature, pk)
699}
700
701#[derive(Debug, Clone, PartialEq, Eq)]
703pub struct VerifiedSuspension {
704 pub grant_ref: String,
706 pub reason: String,
708 pub suspended_at_ms: u64,
710 pub signer_public_key: Vec<u8>,
712}
713
714#[must_use]
736pub fn verify_suspension(
737 payload: &[u8],
738 expected_platform_key: &[u8],
739) -> Option<VerifiedSuspension> {
740 let v: Value = serde_json::from_slice(payload).ok()?;
741 if v.get("kind")?.as_str()? != SUSPENSION_KIND {
742 return None;
743 }
744 let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
745 let reason = v.get("reason")?.as_str()?.to_owned();
746 let suspended_at_ms = v.get("suspended_at_ms")?.as_u64()?;
747 let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
748 let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
749 if pk != expected_platform_key {
750 return None;
751 }
752 let canonical = suspension_canonical(&grant_ref, &reason, suspended_at_ms);
753 if verify(&pk, &canonical, &sig) {
754 Some(VerifiedSuspension {
755 grant_ref,
756 reason,
757 suspended_at_ms,
758 signer_public_key: pk,
759 })
760 } else {
761 None
762 }
763}
764
765#[must_use]
773pub fn live_grant<'a>(
774 grants: &'a [VerifiedGrant],
775 revocations: &[VerifiedRevocation],
776 suspensions: &[VerifiedSuspension],
777) -> Option<&'a VerifiedGrant> {
778 grants.iter().find(|g| {
779 !revocations.iter().any(|r| r.grant_ref == g.grant_ref)
780 && !suspensions.iter().any(|s| s.grant_ref == g.grant_ref)
781 })
782}
783
784fn b64(bytes: &[u8]) -> String {
785 URL_SAFE_NO_PAD.encode(bytes)
786}
787
788fn b64_decode(s: &str) -> Option<Vec<u8>> {
789 URL_SAFE_NO_PAD.decode(s).ok()
790}
791
792#[cfg(test)]
793mod tests {
794 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
795
796 use super::*;
797 use polyc_passkey::softkey::SoftPasskey;
798
799 const RP_ID: &str = "enroll.polychrome.test";
800 const ORIGIN: &str = "https://enroll.polychrome.test";
801
802 fn caps() -> Vec<String> {
803 vec![
804 "arbitrary-egress".to_owned(),
805 "fixed-connector-read".to_owned(),
806 ]
807 }
808
809 fn mint_grant(key: &SoftPasskey) -> Vec<u8> {
812 let covered = caps();
813 let coverage = GrantCoverage {
814 destination: "slack:T1:C42",
815 slot_schema_hash: "sha256:template-abc",
816 covered_capabilities: &covered,
817 };
818 let canonical = grant_canonical(
819 "persona-9",
820 "standup",
821 "post_summary",
822 &coverage,
823 "enr-1",
824 1_700_000_000_000,
825 );
826 let challenge = grant_challenge(&canonical);
827 let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
828 let proof = WebAuthnProof {
829 credential_id: &signed.credential_id,
830 authenticator_data: &signed.authenticator_data,
831 client_data_json: &signed.client_data_json,
832 signature: &signed.signature,
833 };
834 signed_grant_payload(
835 "persona-9",
836 "standup",
837 "post_summary",
838 &coverage,
839 "enr-1",
840 1_700_000_000_000,
841 &proof,
842 )
843 }
844
845 #[test]
846 fn grant_round_trips_and_carries_coverage() {
847 let key = SoftPasskey::from_seed(1);
848 let payload = mint_grant(&key);
849 let g = verify_grant(&payload, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
850 assert_eq!(g.principal, "persona-9");
851 assert_eq!(g.routine, "standup");
852 assert_eq!(g.tool, "post_summary");
853 assert_eq!(g.destination, "slack:T1:C42");
854 assert_eq!(g.slot_schema_hash, "sha256:template-abc");
855 assert_eq!(g.covered_capabilities, caps());
856 assert_eq!(g.enrollment_id, "enr-1");
857 assert_eq!(g.grant_ref, grant_ref(&payload));
858 }
859
860 #[test]
861 fn grant_signed_by_a_different_passkey_is_dropped() {
862 let signer = SoftPasskey::from_seed(1);
863 let attacker = SoftPasskey::from_seed(2);
864 let payload = mint_grant(&signer);
865 assert!(verify_grant(&payload, &attacker.public_key_sec1(), RP_ID, ORIGIN).is_none());
867 assert!(verify_grant(&payload, &signer.public_key_sec1(), RP_ID, ORIGIN).is_some());
869 }
870
871 #[test]
872 fn every_canonical_field_including_kind_is_tamper_evident() {
873 let key = SoftPasskey::from_seed(3);
874 let payload = mint_grant(&key);
875 let pk = key.public_key_sec1();
876 for (field, val) in [
886 ("kind", serde_json::json!(REVOCATION_KIND)),
887 ("principal", serde_json::json!("persona-EVIL")),
888 ("routine", serde_json::json!("other-routine")),
889 ("tool", serde_json::json!("exfiltrate")),
890 ("destination", serde_json::json!("slack:T1:CEVIL")),
891 ("slot_schema_hash", serde_json::json!("sha256:tampered")),
892 (
893 "covered_capabilities",
894 serde_json::json!(["arbitrary-egress", "mutate-external"]),
895 ),
896 ("enrollment_id", serde_json::json!("enr-2")),
897 ("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
898 ] {
899 let mut v: Value = serde_json::from_slice(&payload).unwrap();
900 v[field] = val;
901 assert!(
902 verify_grant(v.to_string().as_bytes(), &pk, RP_ID, ORIGIN).is_none(),
903 "tampered {field} must fail verification"
904 );
905 }
906 assert!(verify_grant(&payload, &pk, RP_ID, ORIGIN).is_some());
908 }
909
910 #[test]
911 fn grant_ref_is_sensitive_to_the_full_payload() {
912 let key = SoftPasskey::from_seed(4);
913 let a = mint_grant(&key);
914 assert_eq!(grant_ref(&a), grant_ref(&a));
915 let b = mint_grant(&SoftPasskey::from_seed(5));
918 assert_ne!(grant_ref(&a), grant_ref(&b));
919 }
920
921 fn mint_revocation(key: &SoftPasskey, grant_ref_hex: &str, revoked_at_ms: u64) -> Vec<u8> {
922 let canonical = revocation_canonical(grant_ref_hex, revoked_at_ms);
923 let challenge = grant_challenge(&canonical);
924 let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
925 let proof = WebAuthnProof {
926 credential_id: &signed.credential_id,
927 authenticator_data: &signed.authenticator_data,
928 client_data_json: &signed.client_data_json,
929 signature: &signed.signature,
930 };
931 signed_revocation_payload(grant_ref_hex, revoked_at_ms, &proof)
932 }
933
934 #[test]
935 fn revocation_round_trips_and_binds_the_grant_ref() {
936 let key = SoftPasskey::from_seed(6);
937 let payload = mint_grant(&key);
938 let gref = grant_ref(&payload);
939 let rev = mint_revocation(&key, &gref, 1_700_000_100_000);
940 let v = verify_revocation(&rev, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
941 assert_eq!(v.grant_ref, gref);
942 assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
943 assert!(
945 verify_revocation(
946 &rev,
947 &SoftPasskey::from_seed(7).public_key_sec1(),
948 RP_ID,
949 ORIGIN
950 )
951 .is_none()
952 );
953 }
954
955 #[test]
956 fn revocation_tampered_grant_ref_fails() {
957 let key = SoftPasskey::from_seed(6);
958 let rev = mint_revocation(&key, &"0".repeat(64), 1);
959 let mut v: Value = serde_json::from_slice(&rev).unwrap();
960 v["grant_ref"] = serde_json::json!("f".repeat(64));
961 assert!(
962 verify_revocation(
963 v.to_string().as_bytes(),
964 &key.public_key_sec1(),
965 RP_ID,
966 ORIGIN
967 )
968 .is_none()
969 );
970 }
971
972 #[test]
973 fn suspension_round_trips_and_is_tamper_evident() {
974 let signer = Signer::from_seed(1);
975 let platform_key = signer.public_key_bytes();
976 let (payload, _sig, _pk) = suspension_payload(
977 "deadbeef",
978 "misbehaving routine",
979 1_700_000_200_000,
980 &signer,
981 );
982 let v = verify_suspension(&payload, &platform_key).expect("verifies");
983 assert_eq!(v.grant_ref, "deadbeef");
984 assert_eq!(v.reason, "misbehaving routine");
985 assert_eq!(v.suspended_at_ms, 1_700_000_200_000);
986 for (field, val) in [
987 ("grant_ref", serde_json::json!("cafe")),
988 ("reason", serde_json::json!("something else")),
989 ("suspended_at_ms", serde_json::json!(0u64)),
990 ] {
991 let mut t: Value = serde_json::from_slice(&payload).unwrap();
992 t[field] = val;
993 assert!(
994 verify_suspension(t.to_string().as_bytes(), &platform_key).is_none(),
995 "tampered {field} must fail"
996 );
997 }
998 }
999
1000 #[test]
1001 fn suspension_signed_by_a_key_other_than_the_expected_platform_key_is_dropped() {
1002 let forger = Signer::from_seed(99);
1011 let real_platform = Signer::from_seed(1);
1012 let (payload, _sig, _pk) =
1013 suspension_payload("deadbeef", "misbehaving routine", 1, &forger);
1014 assert!(verify_suspension(&payload, &real_platform.public_key_bytes()).is_none());
1015 }
1016
1017 #[test]
1018 fn suspension_signed_by_the_expected_platform_key_round_trips() {
1019 let platform = Signer::from_seed(1);
1020 let (payload, _sig, _pk) =
1021 suspension_payload("deadbeef", "misbehaving routine", 1, &platform);
1022 assert!(verify_suspension(&payload, &platform.public_key_bytes()).is_some());
1023 }
1024
1025 #[test]
1026 fn live_grant_ignores_a_wrong_key_suspension_and_leaves_the_grant_live() {
1027 let key = SoftPasskey::from_seed(20);
1036 let payload = mint_grant(&key);
1037 let pk = key.public_key_sec1();
1038 let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
1039 let grants = vec![grant.clone()];
1040
1041 let forger = Signer::from_seed(99);
1042 let real_platform = Signer::from_seed(1);
1043 let (susp_payload, _s, _p) =
1044 suspension_payload(&grant.grant_ref, "kill switch", 1, &forger);
1045 assert!(verify_suspension(&susp_payload, &real_platform.public_key_bytes()).is_none());
1047 assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1049 }
1050
1051 #[test]
1052 fn revocation_and_suspension_cannot_be_confused() {
1053 let signer = Signer::from_seed(1);
1056 let (susp, _s, _p) = suspension_payload("deadbeef", "x", 1, &signer);
1057 assert!(
1058 verify_revocation(
1059 &susp,
1060 &SoftPasskey::from_seed(1).public_key_sec1(),
1061 RP_ID,
1062 ORIGIN
1063 )
1064 .is_none()
1065 );
1066 let grant = mint_grant(&SoftPasskey::from_seed(1));
1067 assert!(verify_suspension(&grant, &signer.public_key_bytes()).is_none());
1068 }
1069
1070 #[test]
1071 fn live_grant_folds_revocations_and_suspensions() {
1072 let key = SoftPasskey::from_seed(8);
1073 let payload = mint_grant(&key);
1074 let pk = key.public_key_sec1();
1075 let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
1076 let grants = vec![grant.clone()];
1077
1078 assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1080
1081 let rev = verify_revocation(
1083 &mint_revocation(&key, &grant.grant_ref, 1),
1084 &pk,
1085 RP_ID,
1086 ORIGIN,
1087 )
1088 .unwrap();
1089 assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
1090
1091 let platform = Signer::from_seed(1);
1093 let (susp_payload, _s, _p) =
1094 suspension_payload(&grant.grant_ref, "kill switch", 1, &platform);
1095 let susp = verify_suspension(&susp_payload, &platform.public_key_bytes()).unwrap();
1096 assert!(live_grant(&grants, &[], std::slice::from_ref(&susp)).is_none());
1097
1098 let other = verify_revocation(
1100 &mint_revocation(&key, &"0".repeat(64), 1),
1101 &pk,
1102 RP_ID,
1103 ORIGIN,
1104 )
1105 .unwrap();
1106 assert_eq!(
1107 live_grant(&grants, std::slice::from_ref(&other), &[]),
1108 Some(&grants[0])
1109 );
1110 }
1111
1112 #[test]
1113 fn garbage_payloads_return_none_not_panic() {
1114 let pk = SoftPasskey::from_seed(1).public_key_sec1();
1115 assert!(verify_grant(b"not json", &pk, RP_ID, ORIGIN).is_none());
1116 assert!(verify_revocation(b"{}", &pk, RP_ID, ORIGIN).is_none());
1117 assert!(verify_suspension(b"[]", &Signer::from_seed(1).public_key_bytes()).is_none());
1118 }
1119
1120 fn persona_keypair(seed: &[u8]) -> polyc_passkey::derive::SigningKeypair {
1123 polyc_passkey::derive::derive_signing_keypair(seed)
1124 }
1125
1126 fn mint_persona_grant(keypair: &polyc_passkey::derive::SigningKeypair) -> Vec<u8> {
1130 let covered = caps();
1131 let coverage = GrantCoverage {
1132 destination: "slack:T1:C42",
1133 slot_schema_hash: "sha256:template-abc",
1134 covered_capabilities: &covered,
1135 };
1136 let canonical = grant_canonical_persona(
1137 "persona-9",
1138 "standup",
1139 "post_summary",
1140 &coverage,
1141 "enr-1",
1142 1_700_000_000_000,
1143 );
1144 let signature = keypair.sign(&canonical);
1145 let public_key = keypair.public_key();
1146 signed_grant_payload_persona(
1147 "persona-9",
1148 "standup",
1149 "post_summary",
1150 &coverage,
1151 "enr-1",
1152 1_700_000_000_000,
1153 &PersonaProof {
1154 signing_public_key: &public_key,
1155 signature: &signature,
1156 },
1157 )
1158 }
1159
1160 #[test]
1161 fn persona_grant_round_trips_and_carries_coverage() {
1162 let keypair = persona_keypair(b"persona-seed-1-------------------");
1163 let payload = mint_persona_grant(&keypair);
1164 let g = verify_persona_signed_grant(&payload, &keypair.public_key()).expect("verifies");
1165 assert_eq!(g.principal, "persona-9");
1166 assert_eq!(g.routine, "standup");
1167 assert_eq!(g.tool, "post_summary");
1168 assert_eq!(g.destination, "slack:T1:C42");
1169 assert_eq!(g.slot_schema_hash, "sha256:template-abc");
1170 assert_eq!(g.covered_capabilities, caps());
1171 assert_eq!(g.enrollment_id, "enr-1");
1172 assert_eq!(g.grant_ref, grant_ref(&payload));
1173 assert_eq!(g.registered_public_key, keypair.public_key());
1174 }
1175
1176 #[test]
1177 fn persona_grant_signed_by_a_different_key_is_dropped() {
1178 let signer = persona_keypair(b"persona-seed-2-------------------");
1179 let attacker = persona_keypair(b"persona-seed-3-------------------");
1180 let payload = mint_persona_grant(&signer);
1181 assert!(verify_persona_signed_grant(&payload, &attacker.public_key()).is_none());
1183 assert!(verify_persona_signed_grant(&payload, &signer.public_key()).is_some());
1185 }
1186
1187 #[test]
1188 fn persona_grant_every_canonical_field_is_tamper_evident() {
1189 let keypair = persona_keypair(b"persona-seed-4-------------------");
1190 let payload = mint_persona_grant(&keypair);
1191 let pk = keypair.public_key();
1192 for (field, val) in [
1193 ("kind", serde_json::json!(REVOCATION_PERSONA_KIND)),
1194 ("principal", serde_json::json!("persona-EVIL")),
1195 ("routine", serde_json::json!("other-routine")),
1196 ("tool", serde_json::json!("exfiltrate")),
1197 ("destination", serde_json::json!("slack:T1:CEVIL")),
1198 ("slot_schema_hash", serde_json::json!("sha256:tampered")),
1199 (
1200 "covered_capabilities",
1201 serde_json::json!(["arbitrary-egress", "mutate-external"]),
1202 ),
1203 ("enrollment_id", serde_json::json!("enr-2")),
1204 ("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
1205 ] {
1206 let mut v: Value = serde_json::from_slice(&payload).unwrap();
1207 v[field] = val;
1208 assert!(
1209 verify_persona_signed_grant(v.to_string().as_bytes(), &pk).is_none(),
1210 "tampered {field} must fail verification"
1211 );
1212 }
1213 assert!(verify_persona_signed_grant(&payload, &pk).is_some());
1214 }
1215
1216 #[test]
1217 fn persona_grant_and_webauthn_grant_never_verify_as_each_other() {
1218 let keypair = persona_keypair(b"persona-seed-5-------------------");
1225 let persona_payload = mint_persona_grant(&keypair);
1226 assert!(
1227 verify_grant(
1228 &persona_payload,
1229 &SoftPasskey::from_seed(1).public_key_sec1(),
1230 RP_ID,
1231 ORIGIN
1232 )
1233 .is_none()
1234 );
1235
1236 let webauthn_payload = mint_grant(&SoftPasskey::from_seed(1));
1237 assert!(verify_persona_signed_grant(&webauthn_payload, &keypair.public_key()).is_none());
1238 }
1239
1240 #[test]
1241 fn persona_revocation_round_trips_and_binds_the_grant_ref() {
1242 let keypair = persona_keypair(b"persona-seed-6-------------------");
1243 let payload = mint_persona_grant(&keypair);
1244 let gref = grant_ref(&payload);
1245 let canonical = revocation_canonical_persona(&gref, 1_700_000_100_000);
1246 let signature = keypair.sign(&canonical);
1247 let public_key = keypair.public_key();
1248 let rev = signed_revocation_payload_persona(
1249 &gref,
1250 1_700_000_100_000,
1251 &PersonaProof {
1252 signing_public_key: &public_key,
1253 signature: &signature,
1254 },
1255 );
1256 let v = verify_persona_signed_revocation(&rev, &keypair.public_key()).expect("verifies");
1257 assert_eq!(v.grant_ref, gref);
1258 assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
1259 let other = persona_keypair(b"persona-seed-7-------------------");
1261 assert!(verify_persona_signed_revocation(&rev, &other.public_key()).is_none());
1262 }
1263
1264 #[test]
1265 fn persona_grant_and_revocation_can_fold_through_live_grant() {
1266 let keypair = persona_keypair(b"persona-seed-8-------------------");
1271 let payload = mint_persona_grant(&keypair);
1272 let grant = verify_persona_signed_grant(&payload, &keypair.public_key()).unwrap();
1273 let grants = vec![grant.clone()];
1274 assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1275
1276 let canonical = revocation_canonical_persona(&grant.grant_ref, 1);
1277 let signature = keypair.sign(&canonical);
1278 let public_key = keypair.public_key();
1279 let rev_payload = signed_revocation_payload_persona(
1280 &grant.grant_ref,
1281 1,
1282 &PersonaProof {
1283 signing_public_key: &public_key,
1284 signature: &signature,
1285 },
1286 );
1287 let rev = verify_persona_signed_revocation(&rev_payload, &keypair.public_key()).unwrap();
1288 assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
1289 }
1290
1291 #[test]
1292 fn persona_grant_garbage_payloads_return_none_not_panic() {
1293 let pk = persona_keypair(b"persona-seed-9-------------------").public_key();
1294 assert!(verify_persona_signed_grant(b"not json", &pk).is_none());
1295 assert!(verify_persona_signed_revocation(b"{}", &pk).is_none());
1296 }
1297}