use polyc_proto::proto::polychrome::approval::v1::AssertedApproval;
use polyc_proto::proto::polychrome::routine::v1::{
AllowDenialRequest, DeleteRoutineRequest, GetRoutineFireRequest,
ListPendingSetupApprovalsRequest, ListRoutineFiresRequest, ListRoutineGrantsRequest,
ListRoutineRefusalsRequest, ListRoutinesRequest, PauseRoutineRequest, RefireAttendedRequest,
RespondSetupApprovalRequest, ResumeRoutineRequest, RevokeGrantRequest,
};
use crate::{Signer, verify};
pub trait AssertsActor: buffa::Message + Clone {
const DOMAIN_PREFIX: &'static [u8];
fn actor(&self) -> Option<&AssertedApproval>;
fn nonce(&self) -> &str;
fn issued_at_unix_ms(&self) -> u64;
#[doc(hidden)]
#[must_use]
fn stamped(self, actor: AssertedApproval, nonce: String, issued_at_unix_ms: u64) -> Self;
}
#[must_use]
pub fn canonical_bytes<M: AssertsActor>(request: &M) -> Vec<u8> {
let mut cleared_actor = request.actor().cloned().unwrap_or_default();
cleared_actor.signature_hex.clear();
let canonical = request.clone().stamped(
cleared_actor,
request.nonce().to_owned(),
request.issued_at_unix_ms(),
);
let encoded = canonical.encode_to_vec();
let mut bytes = Vec::with_capacity(M::DOMAIN_PREFIX.len() + encoded.len());
bytes.extend_from_slice(M::DOMAIN_PREFIX);
bytes.extend_from_slice(&encoded);
bytes
}
#[must_use]
pub fn sign_actor_assertion<M: AssertsActor>(signer: &Signer, request: &M) -> String {
crate::hex::lower(&signer.sign(&canonical_bytes(request)))
}
#[must_use]
pub fn attach_actor_assertion<M: AssertsActor>(
signer: &Signer,
request: M,
actor: AssertedApproval,
nonce: impl Into<String>,
issued_at_unix_ms: u64,
) -> M {
let nonce = nonce.into();
let unsigned = request.stamped(actor.clone(), nonce.clone(), issued_at_unix_ms);
let signature_hex = sign_actor_assertion(signer, &unsigned);
let signed_actor = AssertedApproval {
signature_hex,
..actor
};
unsigned.stamped(signed_actor, nonce, issued_at_unix_ms)
}
#[must_use]
pub fn verify_actor_assertion<'a, K, M>(candidate_public_keys: K, request: &M) -> bool
where
K: IntoIterator<Item = &'a [u8]>,
M: AssertsActor,
{
let Some(actor) = request.actor() else {
return false;
};
let Some(signature) = crate::hex::decode(&actor.signature_hex) else {
return false;
};
let bytes = canonical_bytes(request);
let mut verified = false;
for public_key in candidate_public_keys {
if verify(public_key, &bytes, &signature) {
verified = true;
}
}
verified
}
macro_rules! impl_asserts_actor {
($ty:ty, $domain_prefix:expr) => {
impl AssertsActor for $ty {
const DOMAIN_PREFIX: &'static [u8] = $domain_prefix;
fn actor(&self) -> Option<&AssertedApproval> {
self.actor.as_option()
}
fn nonce(&self) -> &str {
&self.nonce
}
fn issued_at_unix_ms(&self) -> u64 {
self.issued_at_unix_ms
}
fn stamped(
mut self,
actor: AssertedApproval,
nonce: String,
issued_at_unix_ms: u64,
) -> Self {
self.actor = buffa::MessageField::some(actor);
self.nonce = nonce;
self.issued_at_unix_ms = issued_at_unix_ms;
self
}
}
};
}
impl_asserts_actor!(
AllowDenialRequest,
b"polychrome.routine-grant-assertion.allow-denial.v1\0"
);
impl_asserts_actor!(
RevokeGrantRequest,
b"polychrome.routine-grant-assertion.revoke-grant.v1\0"
);
impl_asserts_actor!(
RefireAttendedRequest,
b"polychrome.routine-grant-assertion.refire-attended.v1\0"
);
impl_asserts_actor!(
ListRoutineGrantsRequest,
b"polychrome.routine-grant-assertion.list-routine-grants.v1\0"
);
impl_asserts_actor!(
ListRoutinesRequest,
b"polychrome.routine-grant-assertion.list-routines.v1\0"
);
impl_asserts_actor!(
PauseRoutineRequest,
b"polychrome.routine-grant-assertion.pause-routine.v1\0"
);
impl_asserts_actor!(
ResumeRoutineRequest,
b"polychrome.routine-grant-assertion.resume-routine.v1\0"
);
impl_asserts_actor!(
DeleteRoutineRequest,
b"polychrome.routine-grant-assertion.delete-routine.v1\0"
);
impl_asserts_actor!(
ListRoutineFiresRequest,
b"polychrome.routine-grant-assertion.list-routine-fires.v1\0"
);
impl_asserts_actor!(
GetRoutineFireRequest,
b"polychrome.routine-grant-assertion.get-routine-fire.v1\0"
);
impl_asserts_actor!(
ListPendingSetupApprovalsRequest,
b"polychrome.routine-grant-assertion.list-pending-setup-approvals.v1\0"
);
impl_asserts_actor!(
RespondSetupApprovalRequest,
b"polychrome.routine-grant-assertion.respond-setup-approval.v1\0"
);
impl_asserts_actor!(
ListRoutineRefusalsRequest,
b"polychrome.routine-grant-assertion.list-routine-refusals.v1\0"
);
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use buffa::Message as _;
use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
use super::*;
fn sample_actor() -> AssertedApproval {
AssertedApproval {
edge_id: "chat".to_owned(),
responder: buffa::MessageField::some(ExternalIdentity {
provider: "chat".to_owned(),
scope: "team-1".to_owned(),
external_id: "U123".to_owned(),
display_name: "Ada".to_owned(),
..Default::default()
}),
..Default::default()
}
}
fn keys(public_key: &[u8]) -> [&[u8]; 1] {
[public_key]
}
#[test]
fn round_trips() {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let mut request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
request = attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
assert!(!request.actor.as_option().unwrap().signature_hex.is_empty());
assert!(verify_actor_assertion(keys(&pk), &request));
}
#[test]
fn tampered_routine_fails() {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let mut request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
request = attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
request.routine = "payroll".to_owned();
assert!(
!verify_actor_assertion(keys(&pk), &request),
"swapping the routine after signing must invalidate the signature"
);
}
#[test]
fn tampered_actor_fails() {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let mut request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
request = attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
request.actor.as_option_mut().unwrap().responder =
buffa::MessageField::some(ExternalIdentity {
external_id: "U999".to_owned(),
..Default::default()
});
assert!(!verify_actor_assertion(keys(&pk), &request));
}
#[test]
fn wrong_key_fails() {
let signer = Signer::from_seed(41);
let other = Signer::from_seed(42);
let mut request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
request = attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
assert!(!verify_actor_assertion(
keys(&other.public_key_bytes()),
&request
));
}
struct CrossTypeFixture {
pk: Vec<u8>,
allow: AllowDenialRequest,
revoke: RevokeGrantRequest,
refire: RefireAttendedRequest,
list: ListRoutineGrantsRequest,
list_routines: ListRoutinesRequest,
pause: PauseRoutineRequest,
resume: ResumeRoutineRequest,
delete: DeleteRoutineRequest,
list_fires: ListRoutineFiresRequest,
get_fire: GetRoutineFireRequest,
}
fn cross_type_fixture() -> CrossTypeFixture {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let mut allow = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
allow = attach_actor_assertion(&signer, allow, sample_actor(), "nonce-1", 1_000);
let revoke = RevokeGrantRequest {
routine: allow.routine.clone(),
tool: allow.tool.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let refire = RefireAttendedRequest {
routine: allow.routine.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let list = ListRoutineGrantsRequest {
routine: allow.routine.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let list_routines = ListRoutinesRequest {
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let pause = PauseRoutineRequest {
routine: allow.routine.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let resume = ResumeRoutineRequest {
routine: allow.routine.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let delete = DeleteRoutineRequest {
routine: allow.routine.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let list_fires = ListRoutineFiresRequest {
routine: allow.routine.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
let get_fire = GetRoutineFireRequest {
routine: allow.routine.clone(),
actor: allow.actor.clone(),
nonce: allow.nonce.clone(),
issued_at_unix_ms: allow.issued_at_unix_ms,
..Default::default()
};
CrossTypeFixture {
pk,
allow,
revoke,
refire,
list,
list_routines,
pause,
resume,
delete,
list_fires,
get_fire,
}
}
#[test]
fn a_signature_never_verifies_across_any_request_type() {
let fixture = cross_type_fixture();
let keys = keys(&fixture.pk);
assert!(
!verify_actor_assertion(keys, &fixture.revoke),
"AllowDenialRequest's signature must not verify as RevokeGrantRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.refire),
"AllowDenialRequest's signature must not verify as RefireAttendedRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.list),
"AllowDenialRequest's signature must not verify as ListRoutineGrantsRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.list_routines),
"AllowDenialRequest's signature must not verify as ListRoutinesRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.pause),
"AllowDenialRequest's signature must not verify as PauseRoutineRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.resume),
"AllowDenialRequest's signature must not verify as ResumeRoutineRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.delete),
"AllowDenialRequest's signature must not verify as DeleteRoutineRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.list_fires),
"AllowDenialRequest's signature must not verify as ListRoutineFiresRequest"
);
assert!(
!verify_actor_assertion(keys, &fixture.get_fire),
"AllowDenialRequest's signature must not verify as GetRoutineFireRequest"
);
assert!(verify_actor_assertion(keys, &fixture.allow));
}
#[test]
fn absent_actor_never_verifies() {
let request = AllowDenialRequest {
routine: "standup".to_owned(),
..Default::default()
};
assert!(!verify_actor_assertion(
keys(&Signer::from_seed(1).public_key_bytes()),
&request
));
}
#[test]
fn tampered_nonce_fails() {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let mut request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
request = attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
request.nonce = "nonce-2".to_owned();
assert!(
!verify_actor_assertion(keys(&pk), &request),
"swapping the nonce after signing must invalidate the signature"
);
}
#[test]
fn tampered_issued_at_fails() {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let mut request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
request = attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
request.issued_at_unix_ms = 999_999;
assert!(
!verify_actor_assertion(keys(&pk), &request),
"backdating the timestamp after signing must invalidate the signature"
);
}
#[test]
fn verifies_against_any_candidate_key() {
let signer = Signer::from_seed(41);
let retiring = Signer::from_seed(42);
let retiring_pk = retiring.public_key_bytes();
let active_pk = signer.public_key_bytes();
let request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
let request = attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
let candidates: [&[u8]; 2] = [&retiring_pk, &active_pk];
assert!(verify_actor_assertion(candidates, &request));
}
#[test]
fn garbage_signature_hex_returns_false_not_panic() {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
..Default::default()
};
let mut request =
attach_actor_assertion(&signer, request, sample_actor(), "nonce-1", 1_000);
for garbage in ["not-hex!", "abc", "", "zz", &"ab".repeat(4096)] {
request.actor.as_option_mut().unwrap().signature_hex = garbage.to_owned();
assert!(
!verify_actor_assertion(keys(&pk), &request),
"malformed signature_hex {garbage:?} must return false, not panic"
);
}
}
#[test]
fn undomained_signature_is_rejected() {
let signer = Signer::from_seed(41);
let pk = signer.public_key_bytes();
let mut request = AllowDenialRequest {
routine: "standup".to_owned(),
tool: "send_email".to_owned(),
actor: buffa::MessageField::some(sample_actor()),
nonce: "nonce-1".to_owned(),
issued_at_unix_ms: 1_000,
..Default::default()
};
let raw = request.encode_to_vec();
request.actor.as_option_mut().unwrap().signature_hex =
crate::hex::lower(&signer.sign(&raw));
assert!(!verify_actor_assertion(keys(&pk), &request));
}
#[test]
fn domain_prefixes_are_distinct_and_nul_terminated() {
let prefixes: [&[u8]; 10] = [
AllowDenialRequest::DOMAIN_PREFIX,
RevokeGrantRequest::DOMAIN_PREFIX,
RefireAttendedRequest::DOMAIN_PREFIX,
ListRoutineGrantsRequest::DOMAIN_PREFIX,
ListRoutinesRequest::DOMAIN_PREFIX,
PauseRoutineRequest::DOMAIN_PREFIX,
ResumeRoutineRequest::DOMAIN_PREFIX,
DeleteRoutineRequest::DOMAIN_PREFIX,
ListRoutineFiresRequest::DOMAIN_PREFIX,
GetRoutineFireRequest::DOMAIN_PREFIX,
];
for prefix in prefixes {
assert_eq!(
prefix.last(),
Some(&0u8),
"{prefix:?} must be NUL-terminated"
);
}
for (i, a) in prefixes.iter().enumerate() {
for b in &prefixes[i + 1..] {
assert_ne!(a, b, "every domain prefix must be pairwise distinct");
}
}
}
}