Skip to main content

koi_common/
posture.rs

1//! Trust posture — the mode oracle every mode-transparent primitive consults.
2//!
3//! ADR-020 §0/§1. Koi's native trust vocabulary is "secure/non-secure"
4//! (ADR-016 §2); this extends that single bit into two orthogonal dimensions so
5//! the *same* consumer code path works whether a node is unsecured,
6//! authenticated, or confidential. The dial lives inside Koi's primitives — they
7//! all key off this type, and consumers never branch on it.
8//!
9//! Neutral vocabulary only (STACK-0001 K2): `Open` / `Authenticated` /
10//! `Confidential` are standard security terms, never a consumer codename. A
11//! consumer layer may *alias* the level as its own "degree"; that naming never
12//! enters Koi.
13
14use serde::{Deserialize, Serialize};
15use utoipa::ToSchema;
16
17/// A node's (or a discovered peer's) trust posture: two orthogonal cryptographic
18/// dimensions.
19///
20/// - `signed` — a usable cryptographic identity is present (the node can sign and
21///   speak mTLS). This is exactly Koi's historical "secure" bit (ADR-016 §2).
22/// - `encrypted` — group-key confidentiality is available (the future
23///   Confidential rung; stays `false` until the `seal`/`open` encryption rung
24///   lands, ADR-020 §4).
25///
26/// Every mode-transparent primitive (sign/verify, serve, client_for,
27/// require_auth, seal/open) consults this and adapts. Wire-stable (serde +
28/// schema) so a peer's posture can travel in discovery and the published wire
29/// contract (ADR-020 §9).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ToSchema)]
31pub struct Posture {
32    /// A usable cryptographic identity is present (can sign / speak mTLS).
33    pub signed: bool,
34    /// Group-key confidentiality is available (the future Confidential rung).
35    pub encrypted: bool,
36}
37
38impl Posture {
39    /// No identity, no confidentiality — the default for an unsecured node.
40    pub const OPEN: Posture = Posture {
41        signed: false,
42        encrypted: false,
43    };
44
45    /// Construct from the two dimensions.
46    pub const fn new(signed: bool, encrypted: bool) -> Self {
47        Self { signed, encrypted }
48    }
49
50    /// The named level this posture resolves to.
51    ///
52    /// `encrypted` without `signed` is meaningless (no confidential trust without
53    /// an identity), so any unsigned posture is [`PostureLevel::Open`].
54    pub const fn level(self) -> PostureLevel {
55        match (self.signed, self.encrypted) {
56            (false, _) => PostureLevel::Open,
57            (true, false) => PostureLevel::Authenticated,
58            (true, true) => PostureLevel::Confidential,
59        }
60    }
61
62    /// Back-compat with Koi's native "secure/non-secure" vocabulary (ADR-016 §2):
63    /// a node is "secure" exactly when it holds an identity.
64    pub const fn is_secure(self) -> bool {
65        self.signed
66    }
67}
68
69/// The named trust level derived from a [`Posture`] (ADR-020 §1).
70///
71/// A graduated ladder; each rung is a superset of the last, so the derived
72/// ordering (`Open < Authenticated < Confidential`) answers "at least this
73/// level". Neutral, standard security vocabulary (STACK-0001 K2).
74#[derive(
75    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, ToSchema,
76)]
77#[serde(rename_all = "snake_case")]
78pub enum PostureLevel {
79    /// No identity — plaintext, anonymous (freshness only).
80    Open,
81    /// A cryptographic identity is present — signed / mTLS, authenticated-as-CN.
82    Authenticated,
83    /// Authenticated plus group-key confidentiality (the future rung).
84    Confidential,
85}
86
87impl PostureLevel {
88    /// The stable wire string (snake_case) used in mDNS TXT stamping and the
89    /// published wire contract (ADR-020 §8/§9). The inverse of [`from_wire`].
90    ///
91    /// [`from_wire`]: PostureLevel::from_wire
92    pub const fn as_wire(self) -> &'static str {
93        match self {
94            PostureLevel::Open => "open",
95            PostureLevel::Authenticated => "authenticated",
96            PostureLevel::Confidential => "confidential",
97        }
98    }
99
100    /// Parse a wire string back into a level (the inverse of [`as_wire`]).
101    /// Returns `None` for any unrecognized token.
102    ///
103    /// [`as_wire`]: PostureLevel::as_wire
104    pub fn from_wire(s: &str) -> Option<Self> {
105        match s {
106            "open" => Some(PostureLevel::Open),
107            "authenticated" => Some(PostureLevel::Authenticated),
108            "confidential" => Some(PostureLevel::Confidential),
109            _ => None,
110        }
111    }
112
113    /// The [`Posture`] booleans this level implies — the inverse of
114    /// [`Posture::level`].
115    pub const fn to_posture(self) -> Posture {
116        match self {
117            PostureLevel::Open => Posture::OPEN,
118            PostureLevel::Authenticated => Posture::new(true, false),
119            PostureLevel::Confidential => Posture::new(true, true),
120        }
121    }
122}
123
124impl From<Posture> for PostureLevel {
125    fn from(p: Posture) -> Self {
126        p.level()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn open_is_neither_and_not_secure() {
136        assert_eq!(Posture::OPEN, Posture::new(false, false));
137        assert_eq!(Posture::OPEN.level(), PostureLevel::Open);
138        assert!(!Posture::OPEN.is_secure());
139    }
140
141    #[test]
142    fn default_posture_is_open() {
143        assert_eq!(Posture::default(), Posture::OPEN);
144    }
145
146    #[test]
147    fn signed_only_is_authenticated_and_secure() {
148        let p = Posture::new(true, false);
149        assert_eq!(p.level(), PostureLevel::Authenticated);
150        assert!(p.is_secure());
151    }
152
153    #[test]
154    fn signed_and_encrypted_is_confidential() {
155        let p = Posture::new(true, true);
156        assert_eq!(p.level(), PostureLevel::Confidential);
157        assert!(p.is_secure());
158    }
159
160    #[test]
161    fn encrypted_without_signed_degrades_to_open() {
162        // Confidentiality without an identity is meaningless.
163        let p = Posture::new(false, true);
164        assert_eq!(p.level(), PostureLevel::Open);
165        assert!(!p.is_secure());
166    }
167
168    #[test]
169    fn level_ordering_is_a_graduated_ladder() {
170        assert!(PostureLevel::Open < PostureLevel::Authenticated);
171        assert!(PostureLevel::Authenticated < PostureLevel::Confidential);
172    }
173
174    #[test]
175    fn from_posture_for_level() {
176        assert_eq!(
177            PostureLevel::from(Posture::new(true, false)),
178            PostureLevel::Authenticated
179        );
180    }
181
182    #[test]
183    fn posture_serde_round_trip() {
184        let p = Posture::new(true, false);
185        let json = serde_json::to_string(&p).unwrap();
186        assert_eq!(json, r#"{"signed":true,"encrypted":false}"#);
187        let back: Posture = serde_json::from_str(&json).unwrap();
188        assert_eq!(back, p);
189    }
190
191    #[test]
192    fn posture_level_serializes_snake_case() {
193        assert_eq!(
194            serde_json::to_string(&PostureLevel::Authenticated).unwrap(),
195            r#""authenticated""#
196        );
197        assert_eq!(
198            serde_json::to_string(&PostureLevel::Confidential).unwrap(),
199            r#""confidential""#
200        );
201        let back: PostureLevel = serde_json::from_str(r#""open""#).unwrap();
202        assert_eq!(back, PostureLevel::Open);
203    }
204
205    #[test]
206    fn as_wire_matches_serde_snake_case() {
207        // The TXT wire string must equal the serde token so the contract is one
208        // vocabulary, not two.
209        for level in [
210            PostureLevel::Open,
211            PostureLevel::Authenticated,
212            PostureLevel::Confidential,
213        ] {
214            let serde = serde_json::to_string(&level).unwrap();
215            assert_eq!(format!("\"{}\"", level.as_wire()), serde);
216        }
217    }
218
219    #[test]
220    fn from_wire_round_trips_as_wire() {
221        for level in [
222            PostureLevel::Open,
223            PostureLevel::Authenticated,
224            PostureLevel::Confidential,
225        ] {
226            assert_eq!(PostureLevel::from_wire(level.as_wire()), Some(level));
227        }
228        assert_eq!(PostureLevel::from_wire("bogus"), None);
229        assert_eq!(PostureLevel::from_wire("Authenticated"), None); // case-sensitive
230    }
231
232    #[test]
233    fn to_posture_is_inverse_of_level() {
234        for posture in [
235            Posture::OPEN,
236            Posture::new(true, false),
237            Posture::new(true, true),
238        ] {
239            assert_eq!(posture.level().to_posture(), posture);
240        }
241    }
242}