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 base64::engine::general_purpose::STANDARD as B64;
27use base64::Engine;
28use serde::{Deserialize, Serialize};
29use utoipa::ToSchema;
30
31/// Current envelope wire version. The verifier selects its construction from this
32/// (never from the `Sig.alg` field). v1 = ES256 over the canonical envelope bytes.
33pub const ENVELOPE_V1: u8 = 1;
34
35/// A versioned, signed (or freshness-stamped) message envelope (ADR-020 §3).
36///
37/// `payload`/`nonce`/the signature are carried base64-encoded so the envelope is
38/// JSON/transport-friendly and transport-agnostic (a consumer applies it to HTTP
39/// bodies, its own UDP gossip, anything). In Open posture `sig` is absent.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
41pub struct Envelope {
42    /// Wire version — selects the verification construction (see [`ENVELOPE_V1`]).
43    pub v: u8,
44    /// The signed bytes, base64 (standard) encoded.
45    pub payload: String,
46    /// A random per-message nonce, base64 (standard) encoded — replay uniqueness.
47    pub nonce: String,
48    /// Signer's clock at sign time, unix seconds — drives the freshness window.
49    pub ts: i64,
50    /// The signature block. Absent in Open posture (a freshness-stamped
51    /// passthrough); present and verified in Authenticated posture.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub sig: Option<Sig>,
54}
55
56/// The signature block of an [`Envelope`] (present only when signed).
57///
58/// Carries the signer's leaf certificate so verification is **self-contained**: a
59/// verifier validates the leaf against the pinned CA it already trusts and derives
60/// the authoritative CN + public key from it — never from a claimed field (ADR-020
61/// §3, the carry-cert model). This is what lets verification work on a pure member
62/// node, which keeps no roster of other members' keys.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
64pub struct Sig {
65    /// Signature algorithm. A closed set pinned by the envelope version; the
66    /// verifier still selects its construction from [`Envelope::v`], never trusts
67    /// this field to choose a codepath.
68    pub alg: SigAlg,
69    /// The signature over the canonical envelope bytes, base64 (standard) encoded.
70    pub signature: String,
71    /// The signer's leaf certificate, DER, base64 (standard) encoded. The CN,
72    /// public key, serial, and validity are all read from here (authoritative).
73    pub signer_cert: String,
74}
75
76/// Signature algorithms Koi will produce/accept. Closed set (no agility): a new
77/// algorithm is a new [`Envelope::v`], not a new value negotiated in-band.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
79pub enum SigAlg {
80    /// ECDSA P-256 with SHA-256 (the Koi CA's leaf algorithm).
81    #[serde(rename = "ES256")]
82    Es256,
83}
84
85/// Whether a message is within the replay/freshness window (ADR-020 §3, ±300s).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Freshness {
89    /// Within the freshness window.
90    Fresh,
91    /// Outside the freshness window (too old, or too far in the future).
92    Stale,
93}
94
95/// The verdict of [`verify`](crate::envelope) — an assurance *level*, not a bool
96/// (ADR-020 §3). Read a trusted identity only via [`Assurance::identity`].
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum Assurance {
100    /// No identity claim (Open posture / unsigned). Carries only a freshness verdict.
101    Anonymous { freshness: Freshness },
102    /// Signature valid against a current, non-revoked roster member. Freshness is
103    /// a sub-field so "authenticated" cannot exist without a freshness verdict.
104    Authenticated { cn: String, freshness: Freshness },
105    /// The envelope was rejected; `reason` is a distinct, named cause (never one
106    /// opaque error — the Istio-503 lesson, ADR-020 §13).
107    ///
108    /// `signer_cn` is the **authoritative** CN when — and only when — the carried
109    /// leaf chained to the verifier's pinned CA but is stale (`Expired`/`Revoked`).
110    /// It is `None` for every other reason (`Malformed`, `UnsupportedVersion`,
111    /// `BadSignature`, `UnknownSigner`), because there the CN would be an
112    /// attacker-controllable claim (an unchained or bad-signature leaf can carry
113    /// any CN) and must never be attributed (ADR-022 §2). So `signer_cn` is a
114    /// *trusted* attribution or nothing — safe to log, and enough for a warm
115    /// "your identity expired — rejoin" by name.
116    Rejected {
117        reason: RejectReason,
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        signer_cn: Option<String>,
120    },
121}
122
123impl Assurance {
124    /// The **only** door to a trusted identity: `Some(cn)` iff the envelope is
125    /// both authenticated *and* fresh; `None` otherwise.
126    ///
127    /// This is what makes the natural `if assurance.identity().is_some()` safe and
128    /// `if !matches!(a, Rejected{..})` *insufficient* — a `Stale` or `Anonymous`
129    /// message can never be mistaken for a trusted identity.
130    pub fn identity(&self) -> Option<&str> {
131        match self {
132            Assurance::Authenticated {
133                cn,
134                freshness: Freshness::Fresh,
135            } => Some(cn),
136            _ => None,
137        }
138    }
139
140    /// The **request-bound** identity door (ADR-022 §1): `Some(cn)` iff this
141    /// assurance is a trusted identity ([`identity`](Self::identity) — Authenticated
142    /// *and* Fresh) **and** the envelope's signed payload equals `expected`.
143    ///
144    /// This closes the silent-impersonation footgun in the obvious
145    /// `if a.identity().is_some() { authorize(req) }` — which authorizes a
146    /// *captured* envelope replayed against a *different* request. For request
147    /// authorization, pass the canonical bytes you expected to be signed (typically
148    /// embedding a hash of the request body); authorization succeeds only when the
149    /// signer signed *those* bytes.
150    ///
151    /// `env` must be the same envelope this `Assurance` was produced from. Koi stays
152    /// payload-agnostic — the consumer owns its canonicalization. The comparison is a
153    /// plain equality: the payload was already cryptographically authenticated by
154    /// `verify`, so it is not a secret.
155    pub fn identity_for(&self, env: &Envelope, expected: &[u8]) -> Option<&str> {
156        let cn = self.identity()?;
157        let payload = B64.decode(env.payload.as_bytes()).ok()?;
158        (payload == expected).then_some(cn)
159    }
160
161    /// Whether the message was rejected outright.
162    pub fn is_rejected(&self) -> bool {
163        matches!(self, Assurance::Rejected { .. })
164    }
165}
166
167/// Why an [`Envelope`] failed verification — distinct, named causes so a consumer
168/// or `diagnose()` can act on the specific failure (ADR-020 §13).
169///
170/// Implementation note: an unsigned envelope in Authenticated context produces
171/// [`Assurance::Anonymous`], not `Rejected`; a timestamp outside the freshness
172/// window produces `Authenticated { freshness: Stale }`, not `Rejected`. Only
173/// hard failures (parse error, bad crypto, unknown or revoked signer) reject.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum RejectReason {
177    /// The envelope (or its base64 fields) could not be parsed.
178    Malformed,
179    /// The envelope version is not understood by this verifier.
180    UnsupportedVersion,
181    /// The signature did not verify against the signer's public key.
182    BadSignature,
183    /// The signer's CN is not a current member of the roster (leaf fails to chain
184    /// to the verifier's pinned CA).
185    UnknownSigner,
186    /// The signer's certificate has been revoked.
187    Revoked,
188    /// The signer's certificate has expired.
189    Expired,
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn dummy_sig() -> Sig {
197        Sig {
198            alg: SigAlg::Es256,
199            signature: "c2ln".to_string(),     // base64("sig")
200            signer_cert: "Y2VydA".to_string(), // base64("cert")
201        }
202    }
203
204    #[test]
205    fn identity_door_only_opens_for_authenticated_and_fresh() {
206        let auth_fresh = Assurance::Authenticated {
207            cn: "web-01".to_string(),
208            freshness: Freshness::Fresh,
209        };
210        assert_eq!(auth_fresh.identity(), Some("web-01"));
211
212        let auth_stale = Assurance::Authenticated {
213            cn: "web-01".to_string(),
214            freshness: Freshness::Stale,
215        };
216        assert_eq!(auth_stale.identity(), None);
217
218        let anon = Assurance::Anonymous {
219            freshness: Freshness::Fresh,
220        };
221        assert_eq!(anon.identity(), None);
222
223        let rejected = Assurance::Rejected {
224            reason: RejectReason::BadSignature,
225            signer_cn: None,
226        };
227        assert_eq!(rejected.identity(), None);
228        assert!(rejected.is_rejected());
229    }
230
231    #[test]
232    fn identity_for_binds_authorization_to_the_signed_payload() {
233        let env = Envelope {
234            v: ENVELOPE_V1,
235            payload: B64.encode(b"the-real-request"),
236            nonce: B64.encode(b"n"),
237            ts: 0,
238            sig: None,
239        };
240        let trusted = Assurance::Authenticated {
241            cn: "web-01".to_string(),
242            freshness: Freshness::Fresh,
243        };
244
245        // Matching expected bytes → the request-bound door opens.
246        assert_eq!(
247            trusted.identity_for(&env, b"the-real-request"),
248            Some("web-01")
249        );
250        // A captured envelope replayed against a DIFFERENT request → closed
251        // (the silent-impersonation footgun, closed).
252        assert_eq!(trusted.identity_for(&env, b"a-different-request"), None);
253
254        // Stale / anonymous / rejected never open, even with a matching payload —
255        // identity_for can never be looser than identity().
256        let stale = Assurance::Authenticated {
257            cn: "web-01".to_string(),
258            freshness: Freshness::Stale,
259        };
260        assert_eq!(stale.identity_for(&env, b"the-real-request"), None);
261        let anon = Assurance::Anonymous {
262            freshness: Freshness::Fresh,
263        };
264        assert_eq!(anon.identity_for(&env, b"the-real-request"), None);
265        let rejected = Assurance::Rejected {
266            reason: RejectReason::BadSignature,
267            signer_cn: None,
268        };
269        assert_eq!(rejected.identity_for(&env, b"the-real-request"), None);
270    }
271
272    #[test]
273    fn open_envelope_omits_sig_field() {
274        let env = Envelope {
275            v: ENVELOPE_V1,
276            payload: "aGk".to_string(),
277            nonce: "bm9uY2U".to_string(),
278            ts: 1_700_000_000,
279            sig: None,
280        };
281        let json = serde_json::to_string(&env).unwrap();
282        assert!(!json.contains("sig"));
283        let back: Envelope = serde_json::from_str(&json).unwrap();
284        assert_eq!(back, env);
285    }
286
287    #[test]
288    fn signed_envelope_round_trips() {
289        let env = Envelope {
290            v: ENVELOPE_V1,
291            payload: "aGk".to_string(),
292            nonce: "bm9uY2U".to_string(),
293            ts: 1_700_000_000,
294            sig: Some(dummy_sig()),
295        };
296        let json = serde_json::to_string(&env).unwrap();
297        assert!(json.contains("signer_cert"));
298        let back: Envelope = serde_json::from_str(&json).unwrap();
299        assert_eq!(back, env);
300    }
301
302    #[test]
303    fn sig_alg_serializes_as_es256() {
304        assert_eq!(serde_json::to_string(&SigAlg::Es256).unwrap(), r#""ES256""#);
305    }
306
307    #[test]
308    fn freshness_and_reject_reason_are_snake_case() {
309        assert_eq!(
310            serde_json::to_string(&Freshness::Stale).unwrap(),
311            r#""stale""#
312        );
313        assert_eq!(
314            serde_json::to_string(&RejectReason::BadSignature).unwrap(),
315            r#""bad_signature""#
316        );
317        assert_eq!(
318            serde_json::to_string(&RejectReason::UnsupportedVersion).unwrap(),
319            r#""unsupported_version""#
320        );
321    }
322
323    #[test]
324    fn produced_reject_reasons_are_all_variants() {
325        // Document which RejectReason values the verifier actually produces.
326        // NoSignature, ClockSkew, NameMismatch were removed because the verifier
327        // never emitted them (unsigned→Anonymous, out-of-window→Stale, CN from cert).
328        let reasons = [
329            RejectReason::Malformed,
330            RejectReason::UnsupportedVersion,
331            RejectReason::BadSignature,
332            RejectReason::UnknownSigner,
333            RejectReason::Revoked,
334            RejectReason::Expired,
335        ];
336        for r in &reasons {
337            // Each variant round-trips through serde.
338            let s = serde_json::to_string(r).unwrap();
339            let back: RejectReason = serde_json::from_str(&s).unwrap();
340            assert_eq!(r, &back);
341        }
342    }
343}