uni_plugin/secrets.rs
1//! Sealer/unsealer secret membrane.
2//!
3//! Plugins granted `Capability::Secret { ids }` acquire **opaque handles** to
4//! named secrets — never raw bytes. The handle can be passed to other
5//! capability-gated host imports (e.g., `host-net.http_get_with_secret`)
6//! but cannot be read, logged, or serialized.
7//!
8//! # Threat model
9//!
10//! - **Unreadable**: the plugin's code has no API to extract bytes from
11//! a [`SecretHandle`]. The handle is a host-side index into the
12//! in-process secret store.
13//! - **Untransferable**: handles cannot be serialized to plugin output
14//! batches (verified by the WASM IPC layer's reject list).
15//! - **Scoped**: handles are tied to the issuing [`SecretStore`] and
16//! become invalid on plugin reload (the store is rebuilt).
17//! - **Auditable**: every [`SecretStore::acquire`] call emits a tracing
18//! event so security teams can detect anomalous frequencies.
19
20use std::collections::HashMap;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23use parking_lot::RwLock;
24
25use crate::errors::FnError;
26
27/// Opaque handle to a sealed secret.
28///
29/// The handle is a small, copyable integer; the bytes live behind it in
30/// the [`SecretStore`] and never cross the API boundary in cleartext.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
32pub struct SecretHandle(u64);
33
34impl SecretHandle {
35 /// The (host-private) opaque identifier. Plugin code never reads this.
36 #[must_use]
37 pub fn opaque_id(&self) -> u64 {
38 self.0
39 }
40}
41
42/// In-process store of sealed secrets.
43///
44/// Constructed by the host at Uni instance startup; populated from
45/// secure config (KMS, env vars, secrets manager). Plugins acquire
46/// handles via [`SecretStore::acquire`]; capability-gated host imports
47/// resolve handles back to bytes via [`SecretStore::unseal_for_host_use`]
48/// which is itself private to the framework's host-import implementations.
49#[derive(Debug)]
50pub struct SecretStore {
51 /// Named secrets — `name → bytes`.
52 by_name: RwLock<HashMap<String, Vec<u8>>>,
53 /// Handle → name mapping.
54 by_handle: RwLock<HashMap<u64, String>>,
55 /// Next handle to hand out. Starts at `1` so the first acquire yields
56 /// a non-zero handle; the `id == 0` guard in [`SecretStore::acquire`]
57 /// remains as defense in depth against a counter forced/wrapped to 0.
58 next: AtomicU64,
59}
60
61impl Default for SecretStore {
62 fn default() -> Self {
63 Self {
64 by_name: RwLock::default(),
65 by_handle: RwLock::default(),
66 next: AtomicU64::new(1),
67 }
68 }
69}
70
71impl SecretStore {
72 /// Construct an empty store.
73 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 /// Seal `bytes` under `name`, replacing any previous value.
79 ///
80 /// Host-side only; plugin code never seals secrets.
81 pub fn seal(&self, name: impl Into<String>, bytes: Vec<u8>) {
82 let name = name.into();
83 self.by_name.write().insert(name, bytes);
84 }
85
86 /// Plugin-facing API: acquire a handle for the named secret.
87 ///
88 /// Emits a tracing event so security teams can monitor acquire
89 /// frequency.
90 ///
91 /// # Errors
92 ///
93 /// Returns [`FnError`] with code `0xA00` if `name` is not present.
94 pub fn acquire(&self, name: &str) -> Result<SecretHandle, FnError> {
95 let exists = self.by_name.read().contains_key(name);
96 if !exists {
97 return Err(FnError::new(
98 0xA00,
99 format!("secret `{name}` not found in store"),
100 ));
101 }
102 let id = self.next.fetch_add(1, Ordering::SeqCst);
103 // Reserve `0` so an uninitialized handle (`SecretHandle(0)`) is
104 // never valid — defense in depth against zero-init exfiltration.
105 let id = if id == 0 {
106 self.next.fetch_add(1, Ordering::SeqCst)
107 } else {
108 id
109 };
110 self.by_handle.write().insert(id, name.to_owned());
111 tracing::debug!(secret_id = name, handle_opaque = id, "secret.acquire");
112 Ok(SecretHandle(id))
113 }
114
115 /// Host-only: resolve a handle to its underlying bytes.
116 ///
117 /// Used by host-import implementations (e.g., `http_get_with_secret`)
118 /// to attach the secret to an outbound HTTP header before invoking
119 /// the actual network call. **This must not be exposed to plugin
120 /// code** — it's `pub` within the crate but not re-exported through
121 /// the WIT binding layer.
122 ///
123 /// # Errors
124 ///
125 /// Returns [`FnError`] with code `0xA01` if the handle is invalid
126 /// (e.g., from a different store, or revoked).
127 pub fn unseal_for_host_use(&self, h: SecretHandle) -> Result<Vec<u8>, FnError> {
128 let by_handle = self.by_handle.read();
129 let name = by_handle.get(&h.0).ok_or_else(|| {
130 FnError::new(
131 0xA01,
132 format!("secret handle {} is invalid or revoked", h.0),
133 )
134 })?;
135 let by_name = self.by_name.read();
136 by_name.get(name).cloned().ok_or_else(|| {
137 FnError::new(0xA02, format!("secret `{name}` was sealed but is now gone"))
138 })
139 }
140
141 /// Revoke a handle (e.g., on plugin reload).
142 pub fn revoke(&self, h: SecretHandle) {
143 self.by_handle.write().remove(&h.0);
144 }
145
146 /// Clear every sealed secret (e.g., on Uni shutdown).
147 pub fn clear(&self) {
148 self.by_name.write().clear();
149 self.by_handle.write().clear();
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn acquire_returns_handle_for_sealed_secret() {
159 let s = SecretStore::new();
160 s.seal("api_key", b"sk-test-abc".to_vec());
161 let h = s.acquire("api_key").unwrap();
162 assert_ne!(h.opaque_id(), 0);
163 }
164
165 #[test]
166 fn acquire_missing_secret_errors() {
167 let s = SecretStore::new();
168 assert!(s.acquire("nope").is_err());
169 }
170
171 #[test]
172 fn host_unseal_returns_bytes() {
173 let s = SecretStore::new();
174 s.seal("api_key", b"sk-test-abc".to_vec());
175 let h = s.acquire("api_key").unwrap();
176 let bytes = s.unseal_for_host_use(h).unwrap();
177 assert_eq!(bytes, b"sk-test-abc");
178 }
179
180 #[test]
181 fn revoked_handle_cannot_unseal() {
182 let s = SecretStore::new();
183 s.seal("api_key", b"x".to_vec());
184 let h = s.acquire("api_key").unwrap();
185 s.revoke(h);
186 assert!(s.unseal_for_host_use(h).is_err());
187 }
188
189 #[test]
190 fn separate_stores_handles_dont_cross() {
191 let a = SecretStore::new();
192 let b = SecretStore::new();
193 a.seal("k", b"av".to_vec());
194 b.seal("k", b"bv".to_vec());
195 let ha = a.acquire("k").unwrap();
196 // Store b doesn't know handle ha — invalid.
197 assert!(b.unseal_for_host_use(ha).is_err());
198 }
199
200 #[test]
201 fn acquire_never_returns_zero_handle() {
202 let s = SecretStore::new();
203 s.seal("k", b"v".to_vec());
204 // Force the counter to roll past 0.
205 s.next.store(0, Ordering::SeqCst);
206 let h = s.acquire("k").unwrap();
207 assert_ne!(h.opaque_id(), 0);
208 }
209}