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}