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