use crate::authzen::AuthZenObligationType;
use crate::error::AxonFlowError;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::Serialize;
use std::fmt;
pub const PEP_HANDSHAKE_HEADER: &str = "X-Axonflow-PEP-Handshake";
pub const PEP_HANDSHAKE_PROFILE_V1: u32 = 1;
pub const MAX_PEP_HANDSHAKE_BYTES: usize = 4096;
pub const MAX_PEP_HANDSHAKE_CAPABILITIES: usize = 64;
const MAX_IDENTIFIER_BYTES: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub struct PEPCapability {
pub r#type: String,
pub version: u32,
}
impl PEPCapability {
pub fn new(r#type: impl Into<String>, version: u32) -> Self {
Self {
r#type: r#type.into(),
version,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PEPHandshakeError {
pointer: &'static str,
message: String,
}
impl PEPHandshakeError {
fn new(pointer: &'static str, message: String) -> Self {
Self { pointer, message }
}
pub fn pointer(&self) -> &str {
self.pointer
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for PEPHandshakeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.pointer.is_empty() {
write!(f, "{PEP_HANDSHAKE_HEADER}: {}", self.message)
} else {
write!(
f,
"{PEP_HANDSHAKE_HEADER}: {}: {}",
self.pointer, self.message
)
}
}
}
impl std::error::Error for PEPHandshakeError {}
impl From<PEPHandshakeError> for AxonFlowError {
fn from(e: PEPHandshakeError) -> Self {
AxonFlowError::ConfigError(e.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PEPHandshake {
pep_id: String,
audience: String,
capabilities: Vec<PEPCapability>,
header_value: String,
}
impl PEPHandshake {
pub fn new(
pep_id: impl Into<String>,
audience: impl Into<String>,
capabilities: impl IntoIterator<Item = PEPCapability>,
) -> Result<Self, PEPHandshakeError> {
let pep_id = pep_id.into();
let audience = audience.into();
if !well_formed(&pep_id, is_identifier_start, is_identifier_byte) {
return Err(PEPHandshakeError::new(
"/pep_id",
format!(
"{pep_id:?} is not of the form [a-z0-9][a-z0-9._-]* with at most \
{MAX_IDENTIFIER_BYTES} bytes"
),
));
}
if !well_formed(&audience, is_audience_start, is_audience_byte) {
return Err(PEPHandshakeError::new(
"/audience",
format!(
"{audience:?} is not of the form [A-Za-z0-9][A-Za-z0-9._:/-]* with at most \
{MAX_IDENTIFIER_BYTES} bytes"
),
));
}
let capabilities = canonical_capabilities(capabilities.into_iter().collect())?;
let header_value = encode(&pep_id, &audience, &capabilities);
if header_value.len() > MAX_PEP_HANDSHAKE_BYTES {
return Err(PEPHandshakeError::new(
"",
format!(
"encodes to {} bytes; the header carries at most {MAX_PEP_HANDSHAKE_BYTES}",
header_value.len()
),
));
}
Ok(Self {
pep_id,
audience,
capabilities,
header_value,
})
}
pub fn pep_id(&self) -> &str {
&self.pep_id
}
pub fn audience(&self) -> &str {
&self.audience
}
pub fn capabilities(&self) -> &[PEPCapability] {
&self.capabilities
}
pub fn header_value(&self) -> &str {
&self.header_value
}
}
fn is_identifier_start(b: u8) -> bool {
b.is_ascii_lowercase() || b.is_ascii_digit()
}
fn is_identifier_byte(b: u8) -> bool {
is_identifier_start(b) || matches!(b, b'.' | b'_' | b'-')
}
fn is_audience_start(b: u8) -> bool {
b.is_ascii_alphanumeric()
}
fn is_audience_byte(b: u8) -> bool {
is_audience_start(b) || matches!(b, b'.' | b'_' | b':' | b'/' | b'-')
}
fn well_formed(value: &str, start: fn(u8) -> bool, rest: fn(u8) -> bool) -> bool {
match value.as_bytes() {
[] => false,
bytes if bytes.len() > MAX_IDENTIFIER_BYTES => false,
[first, tail @ ..] => start(*first) && tail.iter().all(|b| rest(*b)),
}
}
fn canonical_capabilities(
mut capabilities: Vec<PEPCapability>,
) -> Result<Vec<PEPCapability>, PEPHandshakeError> {
if capabilities.len() > MAX_PEP_HANDSHAKE_CAPABILITIES {
return Err(PEPHandshakeError::new(
"/capabilities",
format!(
"declares {} capabilities; the platform reads at most \
{MAX_PEP_HANDSHAKE_CAPABILITIES}",
capabilities.len()
),
));
}
capabilities.sort();
for (i, c) in capabilities.iter().enumerate() {
if !AuthZenObligationType::KNOWN_WIRE_VALUES.contains(&c.r#type.as_str()) {
return Err(PEPHandshakeError::new(
"/capabilities",
format!(
"names obligation type {:?}, which is not one of {:?}",
c.r#type,
AuthZenObligationType::KNOWN_WIRE_VALUES
),
));
}
if c.version == 0 {
return Err(PEPHandshakeError::new(
"/capabilities",
format!(
"declares {:?} at version 0; a version is a positive integer",
c.r#type
),
));
}
if i > 0 && capabilities[i - 1] == *c {
return Err(PEPHandshakeError::new(
"/capabilities",
format!(
"declares {:?} at version {} more than once; the platform refuses a \
repeated capability",
c.r#type, c.version
),
));
}
}
Ok(capabilities)
}
fn encode(pep_id: &str, audience: &str, capabilities: &[PEPCapability]) -> String {
#[derive(Serialize)]
struct Document<'a> {
profile_version: u32,
pep_id: &'a str,
audience: &'a str,
capabilities: Vec<Capability<'a>>,
}
#[derive(Serialize)]
struct Capability<'a> {
r#type: &'a str,
version: u32,
}
let document = Document {
profile_version: PEP_HANDSHAKE_PROFILE_V1,
pep_id,
audience,
capabilities: capabilities
.iter()
.map(|c| Capability {
r#type: &c.r#type,
version: c.version,
})
.collect(),
};
let raw = serde_json::to_vec(&document).expect("the handshake document always serializes");
URL_SAFE_NO_PAD.encode(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn caps(pairs: &[(&str, u32)]) -> Vec<PEPCapability> {
pairs
.iter()
.map(|(t, v)| PEPCapability::new(*t, *v))
.collect()
}
fn refused(
pep_id: &str,
audience: &str,
capabilities: Vec<PEPCapability>,
) -> PEPHandshakeError {
PEPHandshake::new(pep_id, audience, capabilities)
.expect_err("the platform refuses this declaration, so construction must too")
}
fn decoded(value: &str) -> Vec<u8> {
URL_SAFE_NO_PAD.decode(value).expect("unpadded base64url")
}
#[test]
fn the_bytes_are_the_platform_encoders() {
let golden: Value =
serde_json::from_str(include_str!("../testdata/pep_handshake_golden.json"))
.expect("golden file");
let vectors = golden["vectors"].as_array().expect("vectors");
assert_eq!(vectors.len(), 4, "every vendored vector is checked");
for v in vectors {
let pairs: Vec<PEPCapability> = v["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.map(|c| {
PEPCapability::new(
c["type"].as_str().expect("type"),
u32::try_from(c["version"].as_u64().expect("version")).expect("u32"),
)
})
.collect();
let declared = PEPHandshake::new(
v["pep_id"].as_str().expect("pep_id"),
v["audience"].as_str().expect("audience"),
pairs,
)
.expect("a platform-encoded declaration is accepted");
assert_eq!(
declared.header_value(),
v["header"].as_str().expect("header"),
"vector {}",
v["id"]
);
}
}
#[test]
fn sixty_four_capabilities_are_built_the_way_the_platform_built_them() {
let mut kinds = AuthZenObligationType::KNOWN_WIRE_VALUES.to_vec();
kinds.sort_unstable();
let pairs: Vec<PEPCapability> = (1..=5)
.flat_map(|v| kinds.iter().map(move |k| PEPCapability::new(*k, v)))
.take(MAX_PEP_HANDSHAKE_CAPABILITIES)
.collect();
let declared = PEPHandshake::new("p", "a", pairs).expect("64 is the cap, not over it");
let golden: Value =
serde_json::from_str(include_str!("../testdata/pep_handshake_golden.json"))
.expect("golden file");
let sixty_four = golden["vectors"]
.as_array()
.expect("vectors")
.iter()
.find(|v| v["id"] == "sixty-four")
.expect("the sixty-four vector");
assert_eq!(
declared.header_value(),
sixty_four["header"].as_str().unwrap()
);
assert_eq!(declared.header_value().len(), 3364);
}
#[test]
fn the_header_is_unpadded_base64url_of_the_canonical_document() {
let declared = PEPHandshake::new(
"gw",
"https://pep.example.test",
caps(&[("field_redact", 2), ("field_redact", 1)]),
)
.unwrap();
let value = declared.header_value();
assert!(!value.contains(['=', '+', '/']), "{value}");
assert_eq!(
decoded(value),
br#"{"profile_version":1,"pep_id":"gw","audience":"https://pep.example.test","capabilities":[{"type":"field_redact","version":1},{"type":"field_redact","version":2}]}"#
);
}
#[test]
fn the_order_of_declaration_does_not_change_the_bytes() {
let given = [
("notification", 3),
("field_redact", 2),
("approval_challenge", 1),
];
let mut reversed = given;
reversed.reverse();
let one = PEPHandshake::new("gw", "a", caps(&given)).unwrap();
let other = PEPHandshake::new("gw", "a", caps(&reversed)).unwrap();
assert_eq!(one.header_value(), other.header_value());
assert_eq!(one, other);
let mut sorted = caps(&given);
sorted.sort();
assert_eq!(one.capabilities(), sorted.as_slice());
}
#[test]
fn an_empty_declaration_is_a_declaration() {
let declared = PEPHandshake::new("gw", "a", Vec::new()).unwrap();
let document: Value = serde_json::from_slice(&decoded(declared.header_value())).unwrap();
assert_eq!(document["capabilities"], serde_json::json!([]));
assert_eq!(document["profile_version"], 1);
}
#[test]
fn pep_id_at_127_and_128_bytes_is_accepted() {
PEPHandshake::new("g".repeat(127), "a", Vec::new()).expect("127 bytes");
PEPHandshake::new("g".repeat(128), "a", Vec::new()).expect("128 bytes");
}
#[test]
fn pep_id_at_129_bytes_is_refused() {
assert_eq!(
refused(&"g".repeat(129), "a", Vec::new()).pointer(),
"/pep_id"
);
}
#[test]
fn pep_id_empty_is_refused() {
assert_eq!(refused("", "a", Vec::new()).pointer(), "/pep_id");
}
#[test]
fn pep_id_grammar_is_the_platforms() {
for bad in [
"Gateway", "client:gw", "-gw", ".gw", "_gw", "gw\n", "gw/1", "g w", "café", ] {
assert_eq!(
refused(bad, "a", Vec::new()).pointer(),
"/pep_id",
"{bad:?}"
);
}
for good in ["a", "0", "gw.request-1", "gw_1", "9-a.b_c"] {
PEPHandshake::new(good, "a", Vec::new()).unwrap_or_else(|e| panic!("{good:?}: {e}"));
}
}
#[test]
fn audience_at_127_and_128_bytes_is_accepted() {
PEPHandshake::new("gw", "A".repeat(127), Vec::new()).expect("127 bytes");
PEPHandshake::new("gw", "A".repeat(128), Vec::new()).expect("128 bytes");
}
#[test]
fn audience_at_129_bytes_is_refused() {
assert_eq!(
refused("gw", &"a".repeat(129), Vec::new()).pointer(),
"/audience"
);
}
#[test]
fn audience_empty_is_refused() {
assert_eq!(refused("gw", "", Vec::new()).pointer(), "/audience");
}
#[test]
fn audience_grammar_is_the_platforms() {
for bad in [
"/aud",
":aud",
"-aud",
"a b",
"aud\n",
"aud?x",
"aud#x",
"ünïcode",
] {
assert_eq!(
refused("gw", bad, Vec::new()).pointer(),
"/audience",
"{bad:?}"
);
}
for good in [
"A",
"https://api.example.com/v1",
"urn:example:aud",
"Mixed.Case_aud-1",
] {
PEPHandshake::new("gw", good, Vec::new()).unwrap_or_else(|e| panic!("{good:?}: {e}"));
}
}
#[test]
fn pep_id_is_reported_before_audience() {
assert_eq!(refused("", "", Vec::new()).pointer(), "/pep_id");
}
fn distinct(n: u32) -> Vec<PEPCapability> {
(1..=n)
.map(|v| PEPCapability::new("field_redact", v))
.collect()
}
#[test]
fn zero_one_and_sixty_four_capabilities_are_accepted() {
for n in [0, 1, 64] {
let declared = PEPHandshake::new("gw", "a", distinct(n))
.unwrap_or_else(|e| panic!("{n} capabilities: {e}"));
assert_eq!(declared.capabilities().len(), n as usize);
}
}
#[test]
fn sixty_five_capabilities_are_refused() {
let e = refused("gw", "a", distinct(65));
assert_eq!(e.pointer(), "/capabilities");
assert!(e.message().contains("65"), "{e}");
}
#[test]
fn the_count_is_checked_before_the_entries() {
let mut over = distinct(64);
over.push(PEPCapability::new("redact_pii", 1));
let e = refused("gw", "a", over);
assert!(e.message().contains("at most 64"), "{e}");
}
#[test]
fn a_repeated_capability_is_refused() {
let e = refused(
"gw",
"a",
caps(&[("field_redact", 1), ("field_mask", 1), ("field_redact", 1)]),
);
assert_eq!(e.pointer(), "/capabilities");
assert!(e.message().contains("more than once"), "{e}");
}
#[test]
fn the_same_type_at_two_versions_is_not_a_repeat() {
PEPHandshake::new("gw", "a", caps(&[("field_redact", 1), ("field_redact", 2)]))
.expect("two versions are two capabilities");
}
#[test]
fn version_zero_is_refused() {
let e = refused("gw", "a", caps(&[("field_redact", 0)]));
assert_eq!(e.pointer(), "/capabilities");
assert!(e.message().contains("version 0"), "{e}");
}
#[test]
fn version_one_is_accepted() {
PEPHandshake::new("gw", "a", caps(&[("field_redact", 1)])).expect("version 1");
}
#[test]
fn an_obligation_type_the_platform_cannot_match_is_refused() {
for bad in ["redact_pii", "Field_Redact", "field_redact ", ""] {
let e = refused("gw", "a", caps(&[(bad, 1)]));
assert_eq!(e.pointer(), "/capabilities", "{bad:?}");
assert!(e.message().contains("not one of"), "{bad:?}: {e}");
}
}
#[test]
fn every_obligation_type_this_build_declares_is_accepted() {
for kind in AuthZenObligationType::KNOWN_WIRE_VALUES {
PEPHandshake::new("gw", "a", caps(&[(*kind, 1)]))
.unwrap_or_else(|e| panic!("{kind}: {e}"));
}
}
#[test]
fn the_vocabulary_is_the_platforms_obligation_types() {
let platform = [
"approval_challenge",
"field_annotate",
"field_hash",
"field_mask",
"field_redact",
"field_remove",
"field_tokenize",
"immutable_audit",
"notification",
"quota_reservation",
"response_filter",
"route_restriction",
"schema_transform",
"step_up_authentication",
];
let mut known = AuthZenObligationType::KNOWN_WIRE_VALUES.to_vec();
known.sort_unstable();
assert_eq!(known, platform);
}
fn declaration_with_document_length(json_len: usize) -> (String, Vec<PEPCapability>) {
for count in 1..=MAX_PEP_HANDSHAKE_CAPABILITIES as u32 {
let capabilities: Vec<PEPCapability> = (0..count)
.map(|i| PEPCapability::new("step_up_authentication", 1_000_000_000 + i))
.collect();
for audience_len in 1..=MAX_IDENTIFIER_BYTES {
let audience = "a".repeat(audience_len);
let len = decoded(&encode("p", &audience, &capabilities)).len();
if len == json_len {
return (audience, capabilities);
}
}
}
panic!("no declaration found with a {json_len}-byte document");
}
#[test]
fn the_byte_cap_is_4096_and_refuses_the_whole_document() {
let (audience, capabilities) = declaration_with_document_length(3072);
let at_cap = PEPHandshake::new("p", audience, capabilities).expect("4096 is the cap");
assert_eq!(at_cap.header_value().len(), 4096);
let (audience, capabilities) = declaration_with_document_length(3073);
assert_eq!(encode("p", &audience, &capabilities).len(), 4098);
let e = refused("p", &audience, capabilities);
assert_eq!(e.pointer(), "", "the document is at fault, not a member");
assert!(e.message().contains("4098"), "{e}");
}
#[test]
fn the_error_names_the_header_and_the_member() {
let e = refused("Gateway", "a", Vec::new());
assert!(
e.to_string()
.starts_with("X-Axonflow-PEP-Handshake: /pep_id: "),
"{e}"
);
let whole = PEPHandshakeError::new("", "encodes to 4098 bytes".into());
assert_eq!(
whole.to_string(),
"X-Axonflow-PEP-Handshake: encodes to 4098 bytes"
);
}
#[test]
fn the_error_converts_into_the_existing_configuration_error() {
let e = refused("Gateway", "a", Vec::new());
let rendered = e.to_string();
match AxonFlowError::from(e) {
AxonFlowError::ConfigError(message) => assert_eq!(message, rendered),
other => panic!("expected ConfigError, got {other:?}"),
}
}
}