Skip to main content

sudp/state/
protected.rs

1//! Decrypted protected state `M`.
2//!
3//! `M` is what `T` transiently materialises inside its trusted boundary after
4//! Phase III.0. It contains:
5//!
6//! - the authority-bearing service secrets `s_o := M[target]`,
7//! - the in-state peer map `Peer := {cid_c → W_c}` for multi-credential
8//!   recoverability (default peer-map policy),
9//! - deployment-specific auxiliary data.
10
11use std::collections::BTreeMap;
12
13use base64::Engine;
14use serde::{Deserialize, Serialize};
15use zeroize::{Zeroize, Zeroizing};
16
17use crate::grant::WrappingKey;
18use crate::Result;
19
20/// Peer map: `{cid_c → W_c}` (default recoverability policy).
21///
22/// `BTreeMap` keys are base64 strings (deterministic ordering on the wire).
23/// The values are wrapping keys for credentials other than the acting one;
24/// Phase III.3 uses them to rewrap the new `K'` under each peer credential.
25pub type PeerMap = BTreeMap<String, WrappingKey>;
26
27/// `M`: the decrypted protected state, accessible only inside `T`'s trusted
28/// boundary.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct ProtectedState {
31    /// `M[target] = s_o`. Keys are target identifiers, values are raw secret
32    /// bytes (e.g. an API key or signing key).
33    #[serde(default)]
34    pub targets: BTreeMap<String, TargetValue>,
35    /// `Peer = {cid → W_c}`, used by Phase III.3 for multi-credential rewrap.
36    #[serde(default)]
37    pub peers: PeerMap,
38    /// Deployment-specific auxiliary state (vault metadata, deployment hints,
39    /// …). Out-of-scope of the protocol; the crate just preserves it.
40    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
41    pub aux: serde_json::Value,
42}
43
44/// Authority-bearing service secret `s_o`. Held as a length-prefixed byte
45/// vector inside `M` so the protocol layer can be opaque to the secret's
46/// semantics (API key, OAuth token, signing key, …).
47#[derive(Clone, Default, Serialize, Deserialize, Zeroize)]
48#[serde(transparent)]
49pub struct TargetValue(#[serde(with = "crate::wire::b64bytes")] pub Vec<u8>);
50
51impl core::fmt::Debug for TargetValue {
52    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
53        write!(f, "TargetValue(<{} bytes redacted>)", self.0.len())
54    }
55}
56
57impl TargetValue {
58    /// Borrow the secret bytes.
59    pub fn as_bytes(&self) -> &[u8] {
60        &self.0
61    }
62
63    /// Construct from raw bytes.
64    pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
65        Self(bytes.into())
66    }
67}
68
69impl Drop for TargetValue {
70    fn drop(&mut self) {
71        self.0.zeroize();
72    }
73}
74
75impl ProtectedState {
76    /// New empty state. Used at Phase I.2 setup before any targets are added.
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Look up `s_o := M[target]`. Returns an error if the
82    /// target is absent.
83    pub fn target(&self, name: &str) -> Result<&[u8]> {
84        self.targets
85            .get(name)
86            .map(|v| v.as_bytes())
87            .ok_or_else(|| crate::Error::TargetNotFound(name.to_string()))
88    }
89
90    /// Insert or replace a target value.
91    pub fn put_target(&mut self, name: impl Into<String>, value: impl Into<Vec<u8>>) {
92        self.targets
93            .insert(name.into(), TargetValue::from_bytes(value));
94    }
95
96    /// Remove a target.
97    pub fn remove_target(&mut self, name: &str) -> Option<TargetValue> {
98        self.targets.remove(name)
99    }
100
101    /// Serialise to canonical JCS-style JSON bytes for sealing under `K`.
102    ///
103    /// Returns a [`Zeroizing<Vec<u8>>`] so the canonical bytes (which contain
104    /// base64-encoded target plaintexts and peer wrapping keys) are wiped on
105    /// drop. The encoder writes directly into the zeroizing buffer **without
106    /// constructing an intermediate `serde_json::Value` tree** — this avoids
107    /// the prior leak path where target bytes' base64 form lived in a
108    /// non-zeroizing `String` inside `Value`.
109    ///
110    /// The structurally fixed shape is `{"aux":…?,"peers":{…},"targets":{…}}`
111    /// (keys sorted lexicographically per JCS). The optional `aux` field
112    /// goes through [`crate::canonical::canonicalize`] which still uses
113    /// `serde_json::Value`; deployments that put sensitive data in `aux`
114    /// trade some zeroize guarantees and should encrypt-before-stuffing.
115    pub fn to_canonical(&self) -> Result<Zeroizing<Vec<u8>>> {
116        let mut out = Zeroizing::new(Vec::with_capacity(256));
117        out.push(b'{');
118        let mut wrote_field = false;
119
120        // "aux" — sorted first lexicographically (a < p < t).
121        if !self.aux.is_null() {
122            write_field_key(&mut out, "aux", &mut wrote_field);
123            // best-effort: aux still routes through serde_json::Value. Caller
124            // should treat aux as non-secret per the to_canonical doc.
125            let aux_bytes = crate::canonical::canonicalize_strict(&self.aux)?;
126            out.extend_from_slice(&aux_bytes);
127        }
128
129        // "peers": {cid → base64(W_c)}
130        write_field_key(&mut out, "peers", &mut wrote_field);
131        out.push(b'{');
132        for (i, (cid_b64, w)) in self.peers.iter().enumerate() {
133            if i > 0 {
134                out.push(b',');
135            }
136            write_json_string(&mut out, cid_b64);
137            out.push(b':');
138            write_base64_string(&mut out, w.as_bytes());
139        }
140        out.push(b'}');
141
142        // "targets": {path → base64(s_o)}
143        write_field_key(&mut out, "targets", &mut wrote_field);
144        out.push(b'{');
145        for (i, (path, val)) in self.targets.iter().enumerate() {
146            if i > 0 {
147                out.push(b',');
148            }
149            write_json_string(&mut out, path);
150            out.push(b':');
151            write_base64_string(&mut out, val.as_bytes());
152        }
153        out.push(b'}');
154
155        out.push(b'}');
156        Ok(out)
157    }
158
159    /// Parse from canonical bytes (after Phase III.0 decryption of `C`).
160    ///
161    /// Goes directly from bytes to `ProtectedState` via the serde visitor
162    /// pattern (no intermediate `serde_json::Value`). Target plaintexts and
163    /// wrapping keys land in [`TargetValue`] / [`WrappingKey`] which both
164    /// `Zeroize` on drop, so the deserialize path is already leak-free.
165    pub fn from_canonical(bytes: &[u8]) -> Result<Self> {
166        serde_json::from_slice(bytes)
167            .map_err(|_| crate::Error::Encoding("ProtectedState canonical parse"))
168    }
169}
170
171// ── canonical-encoding helpers (private) ────────────────────────────────
172
173fn write_field_key(out: &mut Vec<u8>, key: &str, wrote_field: &mut bool) {
174    if *wrote_field {
175        out.push(b',');
176    }
177    *wrote_field = true;
178    write_json_string(out, key);
179    out.push(b':');
180}
181
182/// JSON-encode `s` as a quoted string, written directly to `out` with
183/// minimal allocation. Standard JSON escaping for `\`, `"`, and control
184/// chars; non-ASCII passes through (UTF-8).
185fn write_json_string(out: &mut Vec<u8>, s: &str) {
186    out.push(b'"');
187    for byte in s.bytes() {
188        match byte {
189            b'"' => out.extend_from_slice(b"\\\""),
190            b'\\' => out.extend_from_slice(b"\\\\"),
191            0x08 => out.extend_from_slice(b"\\b"),
192            0x0c => out.extend_from_slice(b"\\f"),
193            b'\n' => out.extend_from_slice(b"\\n"),
194            b'\r' => out.extend_from_slice(b"\\r"),
195            b'\t' => out.extend_from_slice(b"\\t"),
196            c if c < 0x20 => {
197                let s = format!("\\u{:04x}", c);
198                out.extend_from_slice(s.as_bytes());
199            }
200            c => out.push(c),
201        }
202    }
203    out.push(b'"');
204}
205
206/// base64-encode `bytes` into a quoted JSON string, with the intermediate
207/// base64 buffer held in `Zeroizing<String>` so it's wiped on scope exit.
208fn write_base64_string(out: &mut Vec<u8>, bytes: &[u8]) {
209    let b64 = Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(bytes));
210    out.push(b'"');
211    out.extend_from_slice(b64.as_bytes());
212    out.push(b'"');
213}