Skip to main content

koi_common/
envelope.rs

1//! The signed `Envelope` wire type and the `verify` verdict (`Assurance`).
2//!
3//! ADR-020 §3. `sign(bytes) -> Envelope` returns a freshness-stamped passthrough
4//! in Open posture and a real ES256-signed envelope in Authenticated posture —
5//! the consumer can't tell and shouldn't have to. `verify(&Envelope) ->
6//! Assurance` returns an *assurance level*, never a bool, so authorization keys
7//! uniformly off "authenticated-as-CN vs. fresh-but-anonymous".
8//!
9//! These are the **wire types only** (serde-stable, schema'd for the published
10//! contract); the signing/verification *logic* lives in `koi-certmesh` (it needs
11//! the identity key + roster). Honesty note on the nonce: it is replay-*uniqueness*
12//! input to the canonical signing bytes (ADR-020 §3); **Koi keeps no seen-nonce
13//! cache** — application-layer replay defence is the consumer's responsibility.
14//! Two misuse-resistance rules from the prior-art research (ADR-020 §13) are
15//! encoded here:
16//!
17//! 1. **One identity door.** [`Assurance::identity`] is the *only* way to read a
18//!    trusted CN, and it returns `Some` exclusively for authenticated-AND-fresh —
19//!    so the natural `if !rejected { trust }` cannot leak a `Stale` or anonymous
20//!    message (the `verify()`-returns-bool footgun).
21//! 2. **Version selects the construction.** [`Envelope::v`] (not an
22//!    envelope-declared `alg`) picks the verification algorithm from a hard-coded
23//!    table — closing the JWT `alg:"none"` / algorithm-confusion class. The
24//!    [`SigAlg`] set is closed.
25
26use serde::{Deserialize, Serialize};
27use utoipa::ToSchema;
28
29/// Current envelope wire version. The verifier selects its construction from this
30/// (never from the `Sig.alg` field). v1 = ES256 over the canonical envelope bytes.
31pub const ENVELOPE_V1: u8 = 1;
32
33/// A versioned, signed (or freshness-stamped) message envelope (ADR-020 §3).
34///
35/// `payload`/`nonce`/the signature are carried base64-encoded so the envelope is
36/// JSON/transport-friendly and transport-agnostic (a consumer applies it to HTTP
37/// bodies, its own UDP gossip, anything). In Open posture `sig` is absent.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
39pub struct Envelope {
40    /// Wire version — selects the verification construction (see [`ENVELOPE_V1`]).
41    pub v: u8,
42    /// The signed bytes, base64 (standard) encoded.
43    pub payload: String,
44    /// A random per-message nonce, base64 (standard) encoded — replay uniqueness.
45    pub nonce: String,
46    /// Signer's clock at sign time, unix seconds — drives the freshness window.
47    pub ts: i64,
48    /// The signature block. Absent in Open posture (a freshness-stamped
49    /// passthrough); present and verified in Authenticated posture.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub sig: Option<Sig>,
52}
53
54/// The signature block of an [`Envelope`] (present only when signed).
55///
56/// Carries the signer's leaf certificate so verification is **self-contained**: a
57/// verifier validates the leaf against the pinned CA it already trusts and derives
58/// the authoritative CN + public key from it — never from a claimed field (ADR-020
59/// §3, the carry-cert model). This is what lets verification work on a pure member
60/// node, which keeps no roster of other members' keys.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
62pub struct Sig {
63    /// Signature algorithm. A closed set pinned by the envelope version; the
64    /// verifier still selects its construction from [`Envelope::v`], never trusts
65    /// this field to choose a codepath.
66    pub alg: SigAlg,
67    /// The signature over the canonical envelope bytes, base64 (standard) encoded.
68    pub signature: String,
69    /// The signer's leaf certificate, DER, base64 (standard) encoded. The CN,
70    /// public key, serial, and validity are all read from here (authoritative).
71    pub signer_cert: String,
72}
73
74/// Signature algorithms Koi will produce/accept. Closed set (no agility): a new
75/// algorithm is a new [`Envelope::v`], not a new value negotiated in-band.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
77pub enum SigAlg {
78    /// ECDSA P-256 with SHA-256 (the Koi CA's leaf algorithm).
79    #[serde(rename = "ES256")]
80    Es256,
81}
82
83/// Whether a message is within the replay/freshness window (ADR-020 §3, ±300s).
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum Freshness {
87    /// Within the freshness window.
88    Fresh,
89    /// Outside the freshness window (too old, or too far in the future).
90    Stale,
91}
92
93/// The verdict of [`verify`](crate::envelope) — an assurance *level*, not a bool
94/// (ADR-020 §3). Read a trusted identity only via [`Assurance::identity`].
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum Assurance {
98    /// No identity claim (Open posture / unsigned). Carries only a freshness verdict.
99    Anonymous { freshness: Freshness },
100    /// Signature valid against a current, non-revoked roster member. Freshness is
101    /// a sub-field so "authenticated" cannot exist without a freshness verdict.
102    Authenticated { cn: String, freshness: Freshness },
103    /// The envelope was rejected; `reason` is a distinct, named cause (never one
104    /// opaque error — the Istio-503 lesson, ADR-020 §13).
105    Rejected { reason: RejectReason },
106}
107
108impl Assurance {
109    /// The **only** door to a trusted identity: `Some(cn)` iff the envelope is
110    /// both authenticated *and* fresh; `None` otherwise.
111    ///
112    /// This is what makes the natural `if assurance.identity().is_some()` safe and
113    /// `if !matches!(a, Rejected{..})` *insufficient* — a `Stale` or `Anonymous`
114    /// message can never be mistaken for a trusted identity.
115    pub fn identity(&self) -> Option<&str> {
116        match self {
117            Assurance::Authenticated {
118                cn,
119                freshness: Freshness::Fresh,
120            } => Some(cn),
121            _ => None,
122        }
123    }
124
125    /// Whether the message was rejected outright.
126    pub fn is_rejected(&self) -> bool {
127        matches!(self, Assurance::Rejected { .. })
128    }
129}
130
131/// Why an [`Envelope`] failed verification — distinct, named causes so a consumer
132/// or `diagnose()` can act on the specific failure (ADR-020 §13).
133///
134/// Implementation note: an unsigned envelope in Authenticated context produces
135/// [`Assurance::Anonymous`], not `Rejected`; a timestamp outside the freshness
136/// window produces `Authenticated { freshness: Stale }`, not `Rejected`. Only
137/// hard failures (parse error, bad crypto, unknown or revoked signer) reject.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum RejectReason {
141    /// The envelope (or its base64 fields) could not be parsed.
142    Malformed,
143    /// The envelope version is not understood by this verifier.
144    UnsupportedVersion,
145    /// The signature did not verify against the signer's public key.
146    BadSignature,
147    /// The signer's CN is not a current member of the roster (leaf fails to chain
148    /// to the verifier's pinned CA).
149    UnknownSigner,
150    /// The signer's certificate has been revoked.
151    Revoked,
152    /// The signer's certificate has expired.
153    Expired,
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn dummy_sig() -> Sig {
161        Sig {
162            alg: SigAlg::Es256,
163            signature: "c2ln".to_string(),     // base64("sig")
164            signer_cert: "Y2VydA".to_string(), // base64("cert")
165        }
166    }
167
168    #[test]
169    fn identity_door_only_opens_for_authenticated_and_fresh() {
170        let auth_fresh = Assurance::Authenticated {
171            cn: "web-01".to_string(),
172            freshness: Freshness::Fresh,
173        };
174        assert_eq!(auth_fresh.identity(), Some("web-01"));
175
176        let auth_stale = Assurance::Authenticated {
177            cn: "web-01".to_string(),
178            freshness: Freshness::Stale,
179        };
180        assert_eq!(auth_stale.identity(), None);
181
182        let anon = Assurance::Anonymous {
183            freshness: Freshness::Fresh,
184        };
185        assert_eq!(anon.identity(), None);
186
187        let rejected = Assurance::Rejected {
188            reason: RejectReason::BadSignature,
189        };
190        assert_eq!(rejected.identity(), None);
191        assert!(rejected.is_rejected());
192    }
193
194    #[test]
195    fn open_envelope_omits_sig_field() {
196        let env = Envelope {
197            v: ENVELOPE_V1,
198            payload: "aGk".to_string(),
199            nonce: "bm9uY2U".to_string(),
200            ts: 1_700_000_000,
201            sig: None,
202        };
203        let json = serde_json::to_string(&env).unwrap();
204        assert!(!json.contains("sig"));
205        let back: Envelope = serde_json::from_str(&json).unwrap();
206        assert_eq!(back, env);
207    }
208
209    #[test]
210    fn signed_envelope_round_trips() {
211        let env = Envelope {
212            v: ENVELOPE_V1,
213            payload: "aGk".to_string(),
214            nonce: "bm9uY2U".to_string(),
215            ts: 1_700_000_000,
216            sig: Some(dummy_sig()),
217        };
218        let json = serde_json::to_string(&env).unwrap();
219        assert!(json.contains("signer_cert"));
220        let back: Envelope = serde_json::from_str(&json).unwrap();
221        assert_eq!(back, env);
222    }
223
224    #[test]
225    fn sig_alg_serializes_as_es256() {
226        assert_eq!(serde_json::to_string(&SigAlg::Es256).unwrap(), r#""ES256""#);
227    }
228
229    #[test]
230    fn freshness_and_reject_reason_are_snake_case() {
231        assert_eq!(
232            serde_json::to_string(&Freshness::Stale).unwrap(),
233            r#""stale""#
234        );
235        assert_eq!(
236            serde_json::to_string(&RejectReason::BadSignature).unwrap(),
237            r#""bad_signature""#
238        );
239        assert_eq!(
240            serde_json::to_string(&RejectReason::UnsupportedVersion).unwrap(),
241            r#""unsupported_version""#
242        );
243    }
244
245    #[test]
246    fn produced_reject_reasons_are_all_variants() {
247        // Document which RejectReason values the verifier actually produces.
248        // NoSignature, ClockSkew, NameMismatch were removed because the verifier
249        // never emitted them (unsigned→Anonymous, out-of-window→Stale, CN from cert).
250        let reasons = [
251            RejectReason::Malformed,
252            RejectReason::UnsupportedVersion,
253            RejectReason::BadSignature,
254            RejectReason::UnknownSigner,
255            RejectReason::Revoked,
256            RejectReason::Expired,
257        ];
258        for r in &reasons {
259            // Each variant round-trips through serde.
260            let s = serde_json::to_string(r).unwrap();
261            let back: RejectReason = serde_json::from_str(&s).unwrap();
262            assert_eq!(r, &back);
263        }
264    }
265}