use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub const ENVELOPE_V1: u8 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Envelope {
pub v: u8,
pub payload: String,
pub nonce: String,
pub ts: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<Sig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Sig {
pub alg: SigAlg,
pub signature: String,
pub signer_cert: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum SigAlg {
#[serde(rename = "ES256")]
Es256,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Freshness {
Fresh,
Stale,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Assurance {
Anonymous { freshness: Freshness },
Authenticated { cn: String, freshness: Freshness },
Rejected { reason: RejectReason },
}
impl Assurance {
pub fn identity(&self) -> Option<&str> {
match self {
Assurance::Authenticated {
cn,
freshness: Freshness::Fresh,
} => Some(cn),
_ => None,
}
}
pub fn is_rejected(&self) -> bool {
matches!(self, Assurance::Rejected { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RejectReason {
Malformed,
UnsupportedVersion,
BadSignature,
UnknownSigner,
Revoked,
Expired,
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_sig() -> Sig {
Sig {
alg: SigAlg::Es256,
signature: "c2ln".to_string(), signer_cert: "Y2VydA".to_string(), }
}
#[test]
fn identity_door_only_opens_for_authenticated_and_fresh() {
let auth_fresh = Assurance::Authenticated {
cn: "web-01".to_string(),
freshness: Freshness::Fresh,
};
assert_eq!(auth_fresh.identity(), Some("web-01"));
let auth_stale = Assurance::Authenticated {
cn: "web-01".to_string(),
freshness: Freshness::Stale,
};
assert_eq!(auth_stale.identity(), None);
let anon = Assurance::Anonymous {
freshness: Freshness::Fresh,
};
assert_eq!(anon.identity(), None);
let rejected = Assurance::Rejected {
reason: RejectReason::BadSignature,
};
assert_eq!(rejected.identity(), None);
assert!(rejected.is_rejected());
}
#[test]
fn open_envelope_omits_sig_field() {
let env = Envelope {
v: ENVELOPE_V1,
payload: "aGk".to_string(),
nonce: "bm9uY2U".to_string(),
ts: 1_700_000_000,
sig: None,
};
let json = serde_json::to_string(&env).unwrap();
assert!(!json.contains("sig"));
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(back, env);
}
#[test]
fn signed_envelope_round_trips() {
let env = Envelope {
v: ENVELOPE_V1,
payload: "aGk".to_string(),
nonce: "bm9uY2U".to_string(),
ts: 1_700_000_000,
sig: Some(dummy_sig()),
};
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("signer_cert"));
let back: Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(back, env);
}
#[test]
fn sig_alg_serializes_as_es256() {
assert_eq!(serde_json::to_string(&SigAlg::Es256).unwrap(), r#""ES256""#);
}
#[test]
fn freshness_and_reject_reason_are_snake_case() {
assert_eq!(
serde_json::to_string(&Freshness::Stale).unwrap(),
r#""stale""#
);
assert_eq!(
serde_json::to_string(&RejectReason::BadSignature).unwrap(),
r#""bad_signature""#
);
assert_eq!(
serde_json::to_string(&RejectReason::UnsupportedVersion).unwrap(),
r#""unsupported_version""#
);
}
#[test]
fn produced_reject_reasons_are_all_variants() {
let reasons = [
RejectReason::Malformed,
RejectReason::UnsupportedVersion,
RejectReason::BadSignature,
RejectReason::UnknownSigner,
RejectReason::Revoked,
RejectReason::Expired,
];
for r in &reasons {
let s = serde_json::to_string(r).unwrap();
let back: RejectReason = serde_json::from_str(&s).unwrap();
assert_eq!(r, &back);
}
}
}