Skip to main content

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}