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,
NoSignature,
UnsupportedVersion,
BadSignature,
UnknownSigner,
Revoked,
Expired,
ClockSkew,
NameMismatch,
}
#[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::ClockSkew).unwrap(),
r#""clock_skew""#
);
assert_eq!(
serde_json::to_string(&RejectReason::NoSignature).unwrap(),
r#""no_signature""#
);
}
}