Skip to main content

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}