use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use crate::signed::{Envelope, canonical_bytes};
use crate::{Signer, verify};
use polyc_passkey::derive::verify_derived_signature;
use polyc_passkey::{Assertion, WebAuthnProof, challenge_b64, verify_assertion};
pub const GRANT_KIND: &str = "grant.routine.v1";
pub const REVOCATION_KIND: &str = "grant.revocation.v1";
pub const SUSPENSION_KIND: &str = "grant.suspension.v1";
pub const GRANT_PERSONA_KIND: &str = "grant.routine.persona.v1";
pub const REVOCATION_PERSONA_KIND: &str = "grant.revocation.persona.v1";
#[derive(Debug, Clone, Copy)]
pub struct PersonaProof<'a> {
pub signing_public_key: &'a [u8],
pub signature: &'a [u8],
}
#[derive(Debug, Clone, Copy)]
pub struct GrantCoverage<'a> {
pub destination: &'a str,
pub slot_schema_hash: &'a str,
pub covered_capabilities: &'a [String],
}
#[must_use]
pub fn grant_canonical(
principal: &str,
routine: &str,
tool: &str,
coverage: &GrantCoverage<'_>,
enrollment_id: &str,
created_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&GrantCanonical {
kind: GRANT_KIND,
principal,
routine,
tool,
destination: coverage.destination,
slot_schema_hash: coverage.slot_schema_hash,
covered_capabilities: coverage.covered_capabilities,
enrollment_id,
created_at_ms,
})
}
#[derive(Serialize)]
struct GrantCanonical<'a> {
kind: &'static str,
principal: &'a str,
routine: &'a str,
tool: &'a str,
destination: &'a str,
slot_schema_hash: &'a str,
covered_capabilities: &'a [String],
enrollment_id: &'a str,
created_at_ms: u64,
}
#[derive(Serialize)]
struct RevocationCanonical<'a> {
kind: &'static str,
grant_ref: &'a str,
revoked_at_ms: u64,
}
#[derive(Serialize)]
struct SuspensionCanonical<'a> {
kind: &'static str,
grant_ref: &'a str,
reason: &'a str,
suspended_at_ms: u64,
}
#[must_use]
pub fn grant_challenge(canonical: &[u8]) -> String {
challenge_b64(canonical)
}
#[must_use]
pub fn grant_ref(signed_grant_payload: &[u8]) -> String {
crate::hex::lower(&Sha256::digest(signed_grant_payload))
}
#[must_use]
pub fn signed_grant_payload(
principal: &str,
routine: &str,
tool: &str,
coverage: &GrantCoverage<'_>,
enrollment_id: &str,
created_at_ms: u64,
proof: &WebAuthnProof<'_>,
) -> Vec<u8> {
let canonical = grant_canonical(
principal,
routine,
tool,
coverage,
enrollment_id,
created_at_ms,
);
attach_proof(&canonical, proof)
}
fn attach_proof(canonical: &[u8], proof: &WebAuthnProof<'_>) -> Vec<u8> {
let mut map: serde_json::Map<String, Value> =
serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
map.insert(
"credential_id".to_owned(),
Value::String(b64(proof.credential_id)),
);
map.insert(
"authenticator_data".to_owned(),
Value::String(b64(proof.authenticator_data)),
);
map.insert(
"client_data_json".to_owned(),
Value::String(b64(proof.client_data_json)),
);
map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
Value::Object(map).to_string().into_bytes() }
fn verify_passkey_proof(
v: &Value,
canonical: &[u8],
registered_pubkey_sec1: &[u8],
rp_id: &str,
origin: &str,
) -> Option<()> {
let authenticator_data = b64_decode(v.get("authenticator_data")?.as_str()?)?;
let client_data_json = b64_decode(v.get("client_data_json")?.as_str()?)?;
let signature = b64_decode(v.get("signature")?.as_str()?)?;
let assertion = Assertion {
authenticator_data: &authenticator_data,
client_data_json: &client_data_json,
signature: &signature,
};
verify_assertion(
registered_pubkey_sec1,
&assertion,
&grant_challenge(canonical),
rp_id,
origin,
)
.ok()
.map(|_checks| ())
}
fn attach_persona_proof(canonical: &[u8], proof: &PersonaProof<'_>) -> Vec<u8> {
let mut map: serde_json::Map<String, Value> =
serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
map.insert(
"signing_public_key".to_owned(),
Value::String(b64(proof.signing_public_key)),
);
map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
Value::Object(map).to_string().into_bytes() }
fn verify_persona_proof(v: &Value, canonical: &[u8], expected_persona_pubkey: &[u8]) -> Option<()> {
let signing_public_key = b64_decode(v.get("signing_public_key")?.as_str()?)?;
let signature = b64_decode(v.get("signature")?.as_str()?)?;
if signing_public_key != expected_persona_pubkey {
return None;
}
if verify_derived_signature(&signing_public_key, canonical, &signature) {
Some(())
} else {
None
}
}
#[must_use]
pub fn grant_canonical_persona(
principal: &str,
routine: &str,
tool: &str,
coverage: &GrantCoverage<'_>,
enrollment_id: &str,
created_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&GrantCanonical {
kind: GRANT_PERSONA_KIND,
principal,
routine,
tool,
destination: coverage.destination,
slot_schema_hash: coverage.slot_schema_hash,
covered_capabilities: coverage.covered_capabilities,
enrollment_id,
created_at_ms,
})
}
#[must_use]
pub fn signed_grant_payload_persona(
principal: &str,
routine: &str,
tool: &str,
coverage: &GrantCoverage<'_>,
enrollment_id: &str,
created_at_ms: u64,
proof: &PersonaProof<'_>,
) -> Vec<u8> {
let canonical = grant_canonical_persona(
principal,
routine,
tool,
coverage,
enrollment_id,
created_at_ms,
);
attach_persona_proof(&canonical, proof)
}
#[must_use]
pub fn verify_persona_signed_grant(
payload: &[u8],
expected_persona_pubkey: &[u8],
) -> Option<VerifiedGrant> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != GRANT_PERSONA_KIND {
return None;
}
let principal = v.get("principal")?.as_str()?.to_owned();
let routine = v.get("routine")?.as_str()?.to_owned();
let tool = v.get("tool")?.as_str()?.to_owned();
let destination = v.get("destination")?.as_str()?.to_owned();
let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
let covered_capabilities: Vec<String> = v
.get("covered_capabilities")?
.as_array()?
.iter()
.map(|c| c.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()?;
let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
let created_at_ms = v.get("created_at_ms")?.as_u64()?;
let coverage = GrantCoverage {
destination: &destination,
slot_schema_hash: &slot_schema_hash,
covered_capabilities: &covered_capabilities,
};
let canonical = grant_canonical_persona(
&principal,
&routine,
&tool,
&coverage,
&enrollment_id,
created_at_ms,
);
verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
Some(VerifiedGrant {
principal,
routine,
tool,
destination,
slot_schema_hash,
covered_capabilities,
enrollment_id,
created_at_ms,
grant_ref: grant_ref(payload),
registered_public_key: expected_persona_pubkey.to_vec(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedGrant {
pub principal: String,
pub routine: String,
pub tool: String,
pub destination: String,
pub slot_schema_hash: String,
pub covered_capabilities: Vec<String>,
pub enrollment_id: String,
pub created_at_ms: u64,
pub grant_ref: String,
pub registered_public_key: Vec<u8>,
}
#[must_use]
pub fn verify_grant(
payload: &[u8],
registered_pubkey_sec1: &[u8],
rp_id: &str,
origin: &str,
) -> Option<VerifiedGrant> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != GRANT_KIND {
return None;
}
let principal = v.get("principal")?.as_str()?.to_owned();
let routine = v.get("routine")?.as_str()?.to_owned();
let tool = v.get("tool")?.as_str()?.to_owned();
let destination = v.get("destination")?.as_str()?.to_owned();
let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
let covered_capabilities: Vec<String> = v
.get("covered_capabilities")?
.as_array()?
.iter()
.map(|c| c.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()?;
let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
let created_at_ms = v.get("created_at_ms")?.as_u64()?;
let coverage = GrantCoverage {
destination: &destination,
slot_schema_hash: &slot_schema_hash,
covered_capabilities: &covered_capabilities,
};
let canonical = grant_canonical(
&principal,
&routine,
&tool,
&coverage,
&enrollment_id,
created_at_ms,
);
verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
Some(VerifiedGrant {
principal,
routine,
tool,
destination,
slot_schema_hash,
covered_capabilities,
enrollment_id,
created_at_ms,
grant_ref: grant_ref(payload),
registered_public_key: registered_pubkey_sec1.to_vec(),
})
}
#[must_use]
pub fn revocation_canonical(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
canonical_bytes(&RevocationCanonical {
kind: REVOCATION_KIND,
grant_ref,
revoked_at_ms,
})
}
#[must_use]
pub fn signed_revocation_payload(
grant_ref: &str,
revoked_at_ms: u64,
proof: &WebAuthnProof<'_>,
) -> Vec<u8> {
let canonical = revocation_canonical(grant_ref, revoked_at_ms);
attach_proof(&canonical, proof)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRevocation {
pub grant_ref: String,
pub revoked_at_ms: u64,
pub registered_public_key: Vec<u8>,
}
#[must_use]
pub fn verify_revocation(
payload: &[u8],
registered_pubkey_sec1: &[u8],
rp_id: &str,
origin: &str,
) -> Option<VerifiedRevocation> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != REVOCATION_KIND {
return None;
}
let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
let canonical = revocation_canonical(&grant_ref, revoked_at_ms);
verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
Some(VerifiedRevocation {
grant_ref,
revoked_at_ms,
registered_public_key: registered_pubkey_sec1.to_vec(),
})
}
#[must_use]
pub fn revocation_canonical_persona(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
canonical_bytes(&RevocationCanonical {
kind: REVOCATION_PERSONA_KIND,
grant_ref,
revoked_at_ms,
})
}
#[must_use]
pub fn signed_revocation_payload_persona(
grant_ref: &str,
revoked_at_ms: u64,
proof: &PersonaProof<'_>,
) -> Vec<u8> {
let canonical = revocation_canonical_persona(grant_ref, revoked_at_ms);
attach_persona_proof(&canonical, proof)
}
#[must_use]
pub fn verify_persona_signed_revocation(
payload: &[u8],
expected_persona_pubkey: &[u8],
) -> Option<VerifiedRevocation> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != REVOCATION_PERSONA_KIND {
return None;
}
let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
let canonical = revocation_canonical_persona(&grant_ref, revoked_at_ms);
verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
Some(VerifiedRevocation {
grant_ref,
revoked_at_ms,
registered_public_key: expected_persona_pubkey.to_vec(),
})
}
#[must_use]
pub fn suspension_canonical(grant_ref: &str, reason: &str, suspended_at_ms: u64) -> Vec<u8> {
canonical_bytes(&suspension_fields(grant_ref, reason, suspended_at_ms))
}
const fn suspension_fields<'a>(
grant_ref: &'a str,
reason: &'a str,
suspended_at_ms: u64,
) -> SuspensionCanonical<'a> {
SuspensionCanonical {
kind: SUSPENSION_KIND,
grant_ref,
reason,
suspended_at_ms,
}
}
#[must_use]
pub fn suspension_payload(
grant_ref: &str,
reason: &str,
suspended_at_ms: u64,
signer: &Signer,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
suspension_fields(grant_ref, reason, suspended_at_ms),
signer,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedSuspension {
pub grant_ref: String,
pub reason: String,
pub suspended_at_ms: u64,
pub signer_public_key: Vec<u8>,
}
#[must_use]
pub fn verify_suspension(
payload: &[u8],
expected_platform_key: &[u8],
) -> Option<VerifiedSuspension> {
let v: Value = serde_json::from_slice(payload).ok()?;
if v.get("kind")?.as_str()? != SUSPENSION_KIND {
return None;
}
let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let suspended_at_ms = v.get("suspended_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
if pk != expected_platform_key {
return None;
}
let canonical = suspension_canonical(&grant_ref, &reason, suspended_at_ms);
if verify(&pk, &canonical, &sig) {
Some(VerifiedSuspension {
grant_ref,
reason,
suspended_at_ms,
signer_public_key: pk,
})
} else {
None
}
}
#[must_use]
pub fn live_grant<'a>(
grants: &'a [VerifiedGrant],
revocations: &[VerifiedRevocation],
suspensions: &[VerifiedSuspension],
) -> Option<&'a VerifiedGrant> {
grants.iter().find(|g| {
!revocations.iter().any(|r| r.grant_ref == g.grant_ref)
&& !suspensions.iter().any(|s| s.grant_ref == g.grant_ref)
})
}
fn b64(bytes: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(bytes)
}
fn b64_decode(s: &str) -> Option<Vec<u8>> {
URL_SAFE_NO_PAD.decode(s).ok()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use polyc_passkey::softkey::SoftPasskey;
const RP_ID: &str = "enroll.polychrome.test";
const ORIGIN: &str = "https://enroll.polychrome.test";
fn caps() -> Vec<String> {
vec![
"arbitrary-egress".to_owned(),
"fixed-connector-read".to_owned(),
]
}
fn mint_grant(key: &SoftPasskey) -> Vec<u8> {
let covered = caps();
let coverage = GrantCoverage {
destination: "slack:T1:C42",
slot_schema_hash: "sha256:template-abc",
covered_capabilities: &covered,
};
let canonical = grant_canonical(
"persona-9",
"standup",
"post_summary",
&coverage,
"enr-1",
1_700_000_000_000,
);
let challenge = grant_challenge(&canonical);
let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
let proof = WebAuthnProof {
credential_id: &signed.credential_id,
authenticator_data: &signed.authenticator_data,
client_data_json: &signed.client_data_json,
signature: &signed.signature,
};
signed_grant_payload(
"persona-9",
"standup",
"post_summary",
&coverage,
"enr-1",
1_700_000_000_000,
&proof,
)
}
#[test]
fn grant_round_trips_and_carries_coverage() {
let key = SoftPasskey::from_seed(1);
let payload = mint_grant(&key);
let g = verify_grant(&payload, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
assert_eq!(g.principal, "persona-9");
assert_eq!(g.routine, "standup");
assert_eq!(g.tool, "post_summary");
assert_eq!(g.destination, "slack:T1:C42");
assert_eq!(g.slot_schema_hash, "sha256:template-abc");
assert_eq!(g.covered_capabilities, caps());
assert_eq!(g.enrollment_id, "enr-1");
assert_eq!(g.grant_ref, grant_ref(&payload));
}
#[test]
fn grant_signed_by_a_different_passkey_is_dropped() {
let signer = SoftPasskey::from_seed(1);
let attacker = SoftPasskey::from_seed(2);
let payload = mint_grant(&signer);
assert!(verify_grant(&payload, &attacker.public_key_sec1(), RP_ID, ORIGIN).is_none());
assert!(verify_grant(&payload, &signer.public_key_sec1(), RP_ID, ORIGIN).is_some());
}
#[test]
fn every_canonical_field_including_kind_is_tamper_evident() {
let key = SoftPasskey::from_seed(3);
let payload = mint_grant(&key);
let pk = key.public_key_sec1();
for (field, val) in [
("kind", serde_json::json!(REVOCATION_KIND)),
("principal", serde_json::json!("persona-EVIL")),
("routine", serde_json::json!("other-routine")),
("tool", serde_json::json!("exfiltrate")),
("destination", serde_json::json!("slack:T1:CEVIL")),
("slot_schema_hash", serde_json::json!("sha256:tampered")),
(
"covered_capabilities",
serde_json::json!(["arbitrary-egress", "mutate-external"]),
),
("enrollment_id", serde_json::json!("enr-2")),
("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_grant(v.to_string().as_bytes(), &pk, RP_ID, ORIGIN).is_none(),
"tampered {field} must fail verification"
);
}
assert!(verify_grant(&payload, &pk, RP_ID, ORIGIN).is_some());
}
#[test]
fn grant_ref_is_sensitive_to_the_full_payload() {
let key = SoftPasskey::from_seed(4);
let a = mint_grant(&key);
assert_eq!(grant_ref(&a), grant_ref(&a));
let b = mint_grant(&SoftPasskey::from_seed(5));
assert_ne!(grant_ref(&a), grant_ref(&b));
}
fn mint_revocation(key: &SoftPasskey, grant_ref_hex: &str, revoked_at_ms: u64) -> Vec<u8> {
let canonical = revocation_canonical(grant_ref_hex, revoked_at_ms);
let challenge = grant_challenge(&canonical);
let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
let proof = WebAuthnProof {
credential_id: &signed.credential_id,
authenticator_data: &signed.authenticator_data,
client_data_json: &signed.client_data_json,
signature: &signed.signature,
};
signed_revocation_payload(grant_ref_hex, revoked_at_ms, &proof)
}
#[test]
fn revocation_round_trips_and_binds_the_grant_ref() {
let key = SoftPasskey::from_seed(6);
let payload = mint_grant(&key);
let gref = grant_ref(&payload);
let rev = mint_revocation(&key, &gref, 1_700_000_100_000);
let v = verify_revocation(&rev, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
assert_eq!(v.grant_ref, gref);
assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
assert!(
verify_revocation(
&rev,
&SoftPasskey::from_seed(7).public_key_sec1(),
RP_ID,
ORIGIN
)
.is_none()
);
}
#[test]
fn revocation_tampered_grant_ref_fails() {
let key = SoftPasskey::from_seed(6);
let rev = mint_revocation(&key, &"0".repeat(64), 1);
let mut v: Value = serde_json::from_slice(&rev).unwrap();
v["grant_ref"] = serde_json::json!("f".repeat(64));
assert!(
verify_revocation(
v.to_string().as_bytes(),
&key.public_key_sec1(),
RP_ID,
ORIGIN
)
.is_none()
);
}
#[test]
fn suspension_round_trips_and_is_tamper_evident() {
let signer = Signer::from_seed(1);
let platform_key = signer.public_key_bytes();
let (payload, _sig, _pk) = suspension_payload(
"deadbeef",
"misbehaving routine",
1_700_000_200_000,
&signer,
);
let v = verify_suspension(&payload, &platform_key).expect("verifies");
assert_eq!(v.grant_ref, "deadbeef");
assert_eq!(v.reason, "misbehaving routine");
assert_eq!(v.suspended_at_ms, 1_700_000_200_000);
for (field, val) in [
("grant_ref", serde_json::json!("cafe")),
("reason", serde_json::json!("something else")),
("suspended_at_ms", serde_json::json!(0u64)),
] {
let mut t: Value = serde_json::from_slice(&payload).unwrap();
t[field] = val;
assert!(
verify_suspension(t.to_string().as_bytes(), &platform_key).is_none(),
"tampered {field} must fail"
);
}
}
#[test]
fn suspension_signed_by_a_key_other_than_the_expected_platform_key_is_dropped() {
let forger = Signer::from_seed(99);
let real_platform = Signer::from_seed(1);
let (payload, _sig, _pk) =
suspension_payload("deadbeef", "misbehaving routine", 1, &forger);
assert!(verify_suspension(&payload, &real_platform.public_key_bytes()).is_none());
}
#[test]
fn suspension_signed_by_the_expected_platform_key_round_trips() {
let platform = Signer::from_seed(1);
let (payload, _sig, _pk) =
suspension_payload("deadbeef", "misbehaving routine", 1, &platform);
assert!(verify_suspension(&payload, &platform.public_key_bytes()).is_some());
}
#[test]
fn live_grant_ignores_a_wrong_key_suspension_and_leaves_the_grant_live() {
let key = SoftPasskey::from_seed(20);
let payload = mint_grant(&key);
let pk = key.public_key_sec1();
let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
let grants = vec![grant.clone()];
let forger = Signer::from_seed(99);
let real_platform = Signer::from_seed(1);
let (susp_payload, _s, _p) =
suspension_payload(&grant.grant_ref, "kill switch", 1, &forger);
assert!(verify_suspension(&susp_payload, &real_platform.public_key_bytes()).is_none());
assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
}
#[test]
fn revocation_and_suspension_cannot_be_confused() {
let signer = Signer::from_seed(1);
let (susp, _s, _p) = suspension_payload("deadbeef", "x", 1, &signer);
assert!(
verify_revocation(
&susp,
&SoftPasskey::from_seed(1).public_key_sec1(),
RP_ID,
ORIGIN
)
.is_none()
);
let grant = mint_grant(&SoftPasskey::from_seed(1));
assert!(verify_suspension(&grant, &signer.public_key_bytes()).is_none());
}
#[test]
fn live_grant_folds_revocations_and_suspensions() {
let key = SoftPasskey::from_seed(8);
let payload = mint_grant(&key);
let pk = key.public_key_sec1();
let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
let grants = vec![grant.clone()];
assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
let rev = verify_revocation(
&mint_revocation(&key, &grant.grant_ref, 1),
&pk,
RP_ID,
ORIGIN,
)
.unwrap();
assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
let platform = Signer::from_seed(1);
let (susp_payload, _s, _p) =
suspension_payload(&grant.grant_ref, "kill switch", 1, &platform);
let susp = verify_suspension(&susp_payload, &platform.public_key_bytes()).unwrap();
assert!(live_grant(&grants, &[], std::slice::from_ref(&susp)).is_none());
let other = verify_revocation(
&mint_revocation(&key, &"0".repeat(64), 1),
&pk,
RP_ID,
ORIGIN,
)
.unwrap();
assert_eq!(
live_grant(&grants, std::slice::from_ref(&other), &[]),
Some(&grants[0])
);
}
#[test]
fn garbage_payloads_return_none_not_panic() {
let pk = SoftPasskey::from_seed(1).public_key_sec1();
assert!(verify_grant(b"not json", &pk, RP_ID, ORIGIN).is_none());
assert!(verify_revocation(b"{}", &pk, RP_ID, ORIGIN).is_none());
assert!(verify_suspension(b"[]", &Signer::from_seed(1).public_key_bytes()).is_none());
}
fn persona_keypair(seed: &[u8]) -> polyc_passkey::derive::SigningKeypair {
polyc_passkey::derive::derive_signing_keypair(seed)
}
fn mint_persona_grant(keypair: &polyc_passkey::derive::SigningKeypair) -> Vec<u8> {
let covered = caps();
let coverage = GrantCoverage {
destination: "slack:T1:C42",
slot_schema_hash: "sha256:template-abc",
covered_capabilities: &covered,
};
let canonical = grant_canonical_persona(
"persona-9",
"standup",
"post_summary",
&coverage,
"enr-1",
1_700_000_000_000,
);
let signature = keypair.sign(&canonical);
let public_key = keypair.public_key();
signed_grant_payload_persona(
"persona-9",
"standup",
"post_summary",
&coverage,
"enr-1",
1_700_000_000_000,
&PersonaProof {
signing_public_key: &public_key,
signature: &signature,
},
)
}
#[test]
fn persona_grant_round_trips_and_carries_coverage() {
let keypair = persona_keypair(b"persona-seed-1-------------------");
let payload = mint_persona_grant(&keypair);
let g = verify_persona_signed_grant(&payload, &keypair.public_key()).expect("verifies");
assert_eq!(g.principal, "persona-9");
assert_eq!(g.routine, "standup");
assert_eq!(g.tool, "post_summary");
assert_eq!(g.destination, "slack:T1:C42");
assert_eq!(g.slot_schema_hash, "sha256:template-abc");
assert_eq!(g.covered_capabilities, caps());
assert_eq!(g.enrollment_id, "enr-1");
assert_eq!(g.grant_ref, grant_ref(&payload));
assert_eq!(g.registered_public_key, keypair.public_key());
}
#[test]
fn persona_grant_signed_by_a_different_key_is_dropped() {
let signer = persona_keypair(b"persona-seed-2-------------------");
let attacker = persona_keypair(b"persona-seed-3-------------------");
let payload = mint_persona_grant(&signer);
assert!(verify_persona_signed_grant(&payload, &attacker.public_key()).is_none());
assert!(verify_persona_signed_grant(&payload, &signer.public_key()).is_some());
}
#[test]
fn persona_grant_every_canonical_field_is_tamper_evident() {
let keypair = persona_keypair(b"persona-seed-4-------------------");
let payload = mint_persona_grant(&keypair);
let pk = keypair.public_key();
for (field, val) in [
("kind", serde_json::json!(REVOCATION_PERSONA_KIND)),
("principal", serde_json::json!("persona-EVIL")),
("routine", serde_json::json!("other-routine")),
("tool", serde_json::json!("exfiltrate")),
("destination", serde_json::json!("slack:T1:CEVIL")),
("slot_schema_hash", serde_json::json!("sha256:tampered")),
(
"covered_capabilities",
serde_json::json!(["arbitrary-egress", "mutate-external"]),
),
("enrollment_id", serde_json::json!("enr-2")),
("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_persona_signed_grant(v.to_string().as_bytes(), &pk).is_none(),
"tampered {field} must fail verification"
);
}
assert!(verify_persona_signed_grant(&payload, &pk).is_some());
}
#[test]
fn persona_grant_and_webauthn_grant_never_verify_as_each_other() {
let keypair = persona_keypair(b"persona-seed-5-------------------");
let persona_payload = mint_persona_grant(&keypair);
assert!(
verify_grant(
&persona_payload,
&SoftPasskey::from_seed(1).public_key_sec1(),
RP_ID,
ORIGIN
)
.is_none()
);
let webauthn_payload = mint_grant(&SoftPasskey::from_seed(1));
assert!(verify_persona_signed_grant(&webauthn_payload, &keypair.public_key()).is_none());
}
#[test]
fn persona_revocation_round_trips_and_binds_the_grant_ref() {
let keypair = persona_keypair(b"persona-seed-6-------------------");
let payload = mint_persona_grant(&keypair);
let gref = grant_ref(&payload);
let canonical = revocation_canonical_persona(&gref, 1_700_000_100_000);
let signature = keypair.sign(&canonical);
let public_key = keypair.public_key();
let rev = signed_revocation_payload_persona(
&gref,
1_700_000_100_000,
&PersonaProof {
signing_public_key: &public_key,
signature: &signature,
},
);
let v = verify_persona_signed_revocation(&rev, &keypair.public_key()).expect("verifies");
assert_eq!(v.grant_ref, gref);
assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
let other = persona_keypair(b"persona-seed-7-------------------");
assert!(verify_persona_signed_revocation(&rev, &other.public_key()).is_none());
}
#[test]
fn persona_grant_and_revocation_can_fold_through_live_grant() {
let keypair = persona_keypair(b"persona-seed-8-------------------");
let payload = mint_persona_grant(&keypair);
let grant = verify_persona_signed_grant(&payload, &keypair.public_key()).unwrap();
let grants = vec![grant.clone()];
assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
let canonical = revocation_canonical_persona(&grant.grant_ref, 1);
let signature = keypair.sign(&canonical);
let public_key = keypair.public_key();
let rev_payload = signed_revocation_payload_persona(
&grant.grant_ref,
1,
&PersonaProof {
signing_public_key: &public_key,
signature: &signature,
},
);
let rev = verify_persona_signed_revocation(&rev_payload, &keypair.public_key()).unwrap();
assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
}
#[test]
fn persona_grant_garbage_payloads_return_none_not_panic() {
let pk = persona_keypair(b"persona-seed-9-------------------").public_key();
assert!(verify_persona_signed_grant(b"not json", &pk).is_none());
assert!(verify_persona_signed_revocation(b"{}", &pk).is_none());
}
}
#[cfg(test)]
mod canonical_freeze {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn frozen(label: &str, got: &[u8], want: &str) {
assert_eq!(
String::from_utf8(got.to_vec()).unwrap(),
want,
"{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
);
}
const DESTINATION: &str = r#"{"channel":"C1","workspace":"T1"}"#;
const GRANT_REF: &str = "cafe0123";
const CREATED_AT_MS: u64 = 1_700_000_000_000;
const REVOKED_AT_MS: u64 = 1_700_000_100_000;
const SUSPENDED_AT_MS: u64 = 1_700_000_200_000;
const REASON: &str = "policy change";
fn caps() -> Vec<String> {
vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()]
}
#[test]
fn grant_canonicals_are_frozen() {
let caps = caps();
let coverage = GrantCoverage {
destination: DESTINATION,
slot_schema_hash: "sha256:abc",
covered_capabilities: &caps,
};
frozen(
"grant_canonical",
&grant_canonical(
"persona-1",
"routine-1",
"paid_fetch",
&coverage,
"enr-1",
CREATED_AT_MS,
),
GRANT_CANONICAL,
);
frozen(
"grant_canonical_persona",
&grant_canonical_persona(
"persona-1",
"routine-1",
"paid_fetch",
&coverage,
"enr-1",
CREATED_AT_MS,
),
GRANT_PERSONA_CANONICAL,
);
}
#[test]
fn revocation_canonicals_are_frozen() {
frozen(
"revocation_canonical",
&revocation_canonical(GRANT_REF, REVOKED_AT_MS),
REVOCATION_CANONICAL,
);
frozen(
"revocation_canonical_persona",
&revocation_canonical_persona(GRANT_REF, REVOKED_AT_MS),
REVOCATION_PERSONA_CANONICAL,
);
}
#[test]
fn suspension_canonical_and_payload_are_frozen() {
frozen(
"suspension_canonical",
&suspension_canonical(GRANT_REF, REASON, SUSPENDED_AT_MS),
SUSPENSION_CANONICAL,
);
let (full, sig, _) =
suspension_payload(GRANT_REF, REASON, SUSPENDED_AT_MS, &Signer::from_seed(99));
frozen("suspension_payload", &full, SUSPENSION_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), SUSPENSION_SIG);
}
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}"#;
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}"#;
const REVOCATION_CANONICAL: &str =
r#"{"kind":"grant.revocation.v1","grant_ref":"cafe0123","revoked_at_ms":1700000100000}"#;
const REVOCATION_PERSONA_CANONICAL: &str = r#"{"kind":"grant.revocation.persona.v1","grant_ref":"cafe0123","revoked_at_ms":1700000100000}"#;
const SUSPENSION_CANONICAL: &str = r#"{"kind":"grant.suspension.v1","grant_ref":"cafe0123","reason":"policy change","suspended_at_ms":1700000200000}"#;
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"}"#;
const SUSPENSION_SIG: &str = "dd7c046496ba66d2fc531bbbdbdc926a3f400a45a399a2c7668a2a59253d067eecfd6447de4b2842d21331db5aa8fc6154776094cc694399b507c0684459ea01";
}