koi_common/sealed.rs
1//! The `Sealed` confidentiality envelope (ADR-020 §4).
2//!
3//! `seal(bytes) -> Sealed` / `open(&Sealed) -> Opened` are the confidentiality
4//! rung, shipped **today as passthrough**: a `Sealed` carries a signed
5//! [`Envelope`] (integrity + freshness) but is **not encrypted**. Consumers code
6//! against the final API now; the group-key encryption rung becomes a later
7//! Koi-internal upgrade with zero consumer change.
8//!
9//! Passthrough is a built-in downgrade (the STARTTLS/opportunistic-encryption
10//! antipattern), so it is designed against, not left implicit (ADR-020 §13):
11//!
12//! 1. **The version is the single source of truth.** [`Sealed::v`] selects the
13//! `open` construction and the confidentiality level — never a guess. A new
14//! rung is a new version (v1 group-key), not a renegotiated field.
15//! 2. **Confidentiality is type-level.** [`Sealed::confidentiality`] returns
16//! [`Confidentiality`] so passthrough can never be *mistaken* for encrypted;
17//! the level is observable (`/v1/status` `seal:`), not silent.
18//!
19//! These are the **wire types only** (like [`Envelope`]); the seal/open *logic*
20//! (which needs the identity key + CA anchor) lives in `koi-certmesh`.
21
22use serde::{Deserialize, Serialize};
23use utoipa::ToSchema;
24
25use crate::envelope::{Assurance, Envelope};
26
27/// Sealed wire version: **v0 = passthrough** — a signed but unencrypted envelope
28/// (today's rung). Integrity + freshness, no secrecy.
29pub const SEALED_V0_PASSTHROUGH: u8 = 0;
30
31/// Sealed wire version: **v1 = group-key AEAD** — the future Confidential rung.
32/// Reserved; not yet produced. `open` dispatches on the version so v1 slots in
33/// without changing the consumer-facing API, and the v1 derivation will use the
34/// new, K3-distinct HKDF label `b"koi-seal-group-v1"` (defined in
35/// `koi_crypto::key_agreement::SEAL_GROUP_KEY_HKDF_INFO_V1`).
36pub const SEALED_V1_GROUPKEY: u8 = 1;
37
38/// The confidentiality `seal()` currently produces (ADR-020 §4). Today every
39/// `Sealed` is passthrough; this becomes [`Confidentiality::GroupKey`] when the v1
40/// rung lands. `/v1/status` reports it as `seal:`.
41pub const CURRENT_CONFIDENTIALITY: Confidentiality = Confidentiality::None;
42
43/// Type-level confidentiality of a [`Sealed`] message (ADR-020 §4).
44///
45/// Read from the version, never guessed — so a passthrough message cannot be
46/// mistaken for an encrypted one.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum Confidentiality {
50 /// Signed but not encrypted (passthrough): integrity + freshness, no secrecy.
51 None,
52 /// Group-key encrypted (the future rung).
53 GroupKey,
54}
55
56impl Confidentiality {
57 /// The stable wire string for `/v1/status` and the published contract:
58 /// `passthrough` | `groupkey`.
59 pub const fn as_wire(self) -> &'static str {
60 match self {
61 Confidentiality::None => "passthrough",
62 Confidentiality::GroupKey => "groupkey",
63 }
64 }
65}
66
67/// A versioned confidentiality envelope (ADR-020 §4).
68///
69/// Today every `Sealed` is **v0 passthrough**: it wraps a signed [`Envelope`]
70/// (integrity + freshness) and is **not encrypted**. The version is the single
71/// source of truth — `open` selects its construction from it and
72/// [`confidentiality`](Self::confidentiality) reads the level from it.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
74pub struct Sealed {
75 /// Wire version — selects the `open` construction and the confidentiality level.
76 pub v: u8,
77 /// The signed envelope. For v0 it carries the cleartext payload (signed, not
78 /// encrypted); for v1 it will authenticate the AEAD ciphertext.
79 pub envelope: Envelope,
80}
81
82impl Sealed {
83 /// Wrap a signed [`Envelope`] as a **v0 passthrough** `Sealed` (signed, not
84 /// encrypted).
85 pub fn passthrough(envelope: Envelope) -> Self {
86 Self {
87 v: SEALED_V0_PASSTHROUGH,
88 envelope,
89 }
90 }
91
92 /// The type-level confidentiality (ADR-020 §4), read from the version. Unknown
93 /// versions conservatively read as [`Confidentiality::None`] — never claim a
94 /// secrecy this verifier can't provide (and `open` rejects unknown versions
95 /// anyway).
96 pub fn confidentiality(&self) -> Confidentiality {
97 match self.v {
98 SEALED_V1_GROUPKEY => Confidentiality::GroupKey,
99 _ => Confidentiality::None,
100 }
101 }
102}
103
104/// The result of `open` (ADR-020 §4): the recovered bytes plus the trust state
105/// they arrived with. `open` returns this **only** when the inner envelope was
106/// intact — a rejected (tampered / unknown-signer / expired / revoked) message
107/// never yields bytes (misuse-resistance, ADR-020 §13). Read a trusted identity
108/// via `assurance.identity()`.
109#[derive(Clone)]
110pub struct Opened {
111 /// The recovered plaintext bytes.
112 pub payload: Vec<u8>,
113 /// The assurance over the inner signed envelope (`Anonymous` on an Open node /
114 /// unsigned passthrough, `Authenticated{cn}` when signed by a mesh member).
115 pub assurance: Assurance,
116 /// What confidentiality protected the message in transit (today: `None`).
117 pub confidentiality: Confidentiality,
118}
119
120impl std::fmt::Debug for Opened {
121 /// Redacts the payload (it may be a secret); shows only its length + trust state.
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.debug_struct("Opened")
124 .field("payload_len", &self.payload.len())
125 .field("assurance", &self.assurance)
126 .field("confidentiality", &self.confidentiality)
127 .finish()
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::envelope::{Envelope, Freshness, ENVELOPE_V1};
135
136 fn open_envelope() -> Envelope {
137 Envelope {
138 v: ENVELOPE_V1,
139 payload: "aGk".to_string(),
140 nonce: "bm9uY2U".to_string(),
141 ts: 1_700_000_000,
142 sig: None,
143 }
144 }
145
146 #[test]
147 fn passthrough_is_v0_and_not_encrypted() {
148 let s = Sealed::passthrough(open_envelope());
149 assert_eq!(s.v, SEALED_V0_PASSTHROUGH);
150 assert_eq!(s.confidentiality(), Confidentiality::None);
151 }
152
153 #[test]
154 fn v1_version_reads_as_groupkey() {
155 let s = Sealed {
156 v: SEALED_V1_GROUPKEY,
157 envelope: open_envelope(),
158 };
159 assert_eq!(s.confidentiality(), Confidentiality::GroupKey);
160 }
161
162 #[test]
163 fn unknown_version_is_conservatively_not_encrypted() {
164 let s = Sealed {
165 v: 99,
166 envelope: open_envelope(),
167 };
168 assert_eq!(s.confidentiality(), Confidentiality::None);
169 }
170
171 #[test]
172 fn confidentiality_wire_strings() {
173 assert_eq!(Confidentiality::None.as_wire(), "passthrough");
174 assert_eq!(Confidentiality::GroupKey.as_wire(), "groupkey");
175 // The reported current level is passthrough until the v1 rung lands.
176 assert_eq!(CURRENT_CONFIDENTIALITY.as_wire(), "passthrough");
177 }
178
179 #[test]
180 fn confidentiality_serializes_snake_case() {
181 assert_eq!(
182 serde_json::to_string(&Confidentiality::GroupKey).unwrap(),
183 r#""group_key""#
184 );
185 }
186
187 #[test]
188 fn sealed_round_trips() {
189 let s = Sealed::passthrough(open_envelope());
190 let json = serde_json::to_string(&s).unwrap();
191 let back: Sealed = serde_json::from_str(&json).unwrap();
192 assert_eq!(s, back);
193 }
194
195 #[test]
196 fn opened_debug_redacts_payload() {
197 let opened = Opened {
198 payload: b"super-secret-bytes".to_vec(),
199 assurance: Assurance::Anonymous {
200 freshness: Freshness::Fresh,
201 },
202 confidentiality: Confidentiality::None,
203 };
204 let dbg = format!("{opened:?}");
205 assert!(dbg.contains("payload_len"));
206 assert!(
207 !dbg.contains("super-secret"),
208 "Debug must not leak the payload"
209 );
210 }
211}