Skip to main content

im_core/internal/platform_secret/
mod.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
2#[cfg(test)]
3use chacha20poly1305::aead::{Aead, KeyInit, Payload};
4#[cfg(test)]
5use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
6use rand::rngs::OsRng;
7use rand::RngCore;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use zeroize::Zeroize;
11
12pub const DEVICE_VAULT_ROOT_KEY_LEN: usize = 32;
13const PLATFORM_PROTECTED_SECRET_SCHEMA_VERSION: u32 = 1;
14const MEMORY_PROTECTOR_NONCE_LEN: usize = 12;
15
16pub struct SecretBytes {
17    inner: Vec<u8>,
18}
19
20impl SecretBytes {
21    pub fn from_vec(inner: Vec<u8>) -> Self {
22        Self { inner }
23    }
24
25    pub fn expose_secret(&self) -> &[u8] {
26        &self.inner
27    }
28}
29
30impl Drop for SecretBytes {
31    fn drop(&mut self) {
32        self.inner.zeroize();
33    }
34}
35
36impl fmt::Debug for SecretBytes {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.debug_struct("SecretBytes")
39            .field("len", &self.inner.len())
40            .field("value", &"[REDACTED]")
41            .finish()
42    }
43}
44
45pub struct DeviceVaultRootKey {
46    bytes: [u8; DEVICE_VAULT_ROOT_KEY_LEN],
47}
48
49impl DeviceVaultRootKey {
50    pub fn generate() -> Self {
51        let mut bytes = [0_u8; DEVICE_VAULT_ROOT_KEY_LEN];
52        OsRng.fill_bytes(&mut bytes);
53        Self { bytes }
54    }
55
56    pub fn from_bytes(bytes: [u8; DEVICE_VAULT_ROOT_KEY_LEN]) -> Self {
57        Self { bytes }
58    }
59
60    pub fn try_from_secret(secret: &SecretBytes) -> crate::ImResult<Self> {
61        let bytes = secret.expose_secret();
62        if bytes.len() != DEVICE_VAULT_ROOT_KEY_LEN {
63            return Err(crate::ImError::Serialization {
64                detail: format!("device vault root key must be {DEVICE_VAULT_ROOT_KEY_LEN} bytes"),
65            });
66        }
67        let mut out = [0_u8; DEVICE_VAULT_ROOT_KEY_LEN];
68        out.copy_from_slice(bytes);
69        Ok(Self { bytes: out })
70    }
71
72    pub(crate) fn expose_secret(&self) -> &[u8; DEVICE_VAULT_ROOT_KEY_LEN] {
73        &self.bytes
74    }
75}
76
77impl Drop for DeviceVaultRootKey {
78    fn drop(&mut self) {
79        self.bytes.zeroize();
80    }
81}
82
83impl fmt::Debug for DeviceVaultRootKey {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.debug_struct("DeviceVaultRootKey")
86            .field("len", &DEVICE_VAULT_ROOT_KEY_LEN)
87            .field("value", &"[REDACTED]")
88            .finish()
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub(crate) struct PlatformSecretLabel {
94    pub(crate) workspace_id: String,
95    pub(crate) device_id: String,
96    pub(crate) version: u32,
97}
98
99impl PlatformSecretLabel {
100    pub(crate) fn vault_root_key(
101        workspace_id: impl Into<String>,
102        device_id: impl Into<String>,
103    ) -> Self {
104        Self {
105            workspace_id: workspace_id.into(),
106            device_id: device_id.into(),
107            version: 1,
108        }
109    }
110
111    pub(crate) fn item_name(&self) -> String {
112        format!(
113            "awiki.vault.{}.{}.v{}",
114            self.workspace_id, self.device_id, self.version
115        )
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub(crate) enum PlatformProtectionScope {
121    CurrentUser,
122    CurrentLoginSession,
123    OperatorProvided,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub(crate) struct PlatformProtectionPolicy {
128    pub(crate) no_prompt: bool,
129    pub(crate) user_presence_required: bool,
130    pub(crate) scope: PlatformProtectionScope,
131}
132
133impl PlatformProtectionPolicy {
134    pub(crate) fn no_prompt_current_user() -> Self {
135        Self {
136            no_prompt: true,
137            user_presence_required: false,
138            scope: PlatformProtectionScope::CurrentUser,
139        }
140    }
141
142    pub(crate) fn validate_no_prompt(&self) -> crate::ImResult<()> {
143        if !self.no_prompt || self.user_presence_required {
144            return Err(crate::ImError::unsupported(
145                "platform secret protector requires user presence; awiki vault unlock must be no-prompt",
146            ));
147        }
148        Ok(())
149    }
150}
151
152#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub(crate) struct ProtectedSecret {
154    pub(crate) schema_version: u32,
155    pub(crate) label: String,
156    pub(crate) protector_kind: String,
157    pub(crate) policy: PlatformProtectionPolicy,
158    pub(crate) protected_blob_b64u: String,
159}
160
161impl ProtectedSecret {
162    fn new(
163        label: String,
164        protector_kind: impl Into<String>,
165        policy: PlatformProtectionPolicy,
166        protected_blob: &[u8],
167    ) -> Self {
168        Self {
169            schema_version: PLATFORM_PROTECTED_SECRET_SCHEMA_VERSION,
170            label,
171            protector_kind: protector_kind.into(),
172            policy,
173            protected_blob_b64u: URL_SAFE_NO_PAD.encode(protected_blob),
174        }
175    }
176
177    fn protected_blob(&self) -> crate::ImResult<Vec<u8>> {
178        URL_SAFE_NO_PAD
179            .decode(self.protected_blob_b64u.trim())
180            .map_err(|_| crate::ImError::Serialization {
181                detail: "protected platform secret blob must be base64url without padding"
182                    .to_owned(),
183            })
184    }
185}
186
187impl fmt::Debug for ProtectedSecret {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        f.debug_struct("ProtectedSecret")
190            .field("schema_version", &self.schema_version)
191            .field("label", &self.label)
192            .field("protector_kind", &self.protector_kind)
193            .field("policy", &self.policy)
194            .field("protected_blob", &"[REDACTED]")
195            .finish()
196    }
197}
198
199pub(crate) trait PlatformProtector: Send + Sync {
200    fn protector_kind(&self) -> &'static str;
201
202    fn policy(&self) -> PlatformProtectionPolicy;
203
204    fn protect(
205        &self,
206        label: &PlatformSecretLabel,
207        plaintext: &SecretBytes,
208    ) -> crate::ImResult<ProtectedSecret>;
209
210    fn unprotect(
211        &self,
212        label: &PlatformSecretLabel,
213        protected: &ProtectedSecret,
214    ) -> crate::ImResult<SecretBytes>;
215
216    fn delete(&self, label: &PlatformSecretLabel) -> crate::ImResult<()>;
217}
218
219#[derive(Debug, Clone)]
220pub(crate) struct UnavailablePlatformProtector {
221    reason: String,
222}
223
224impl UnavailablePlatformProtector {
225    pub(crate) fn new(reason: impl Into<String>) -> Self {
226        Self {
227            reason: reason.into(),
228        }
229    }
230
231    fn unavailable(&self) -> crate::ImError {
232        crate::ImError::unsupported(format!(
233            "no-prompt platform secret backend unavailable: {}",
234            self.reason
235        ))
236    }
237}
238
239impl PlatformProtector for UnavailablePlatformProtector {
240    fn protector_kind(&self) -> &'static str {
241        "unavailable"
242    }
243
244    fn policy(&self) -> PlatformProtectionPolicy {
245        PlatformProtectionPolicy::no_prompt_current_user()
246    }
247
248    fn protect(
249        &self,
250        _label: &PlatformSecretLabel,
251        _plaintext: &SecretBytes,
252    ) -> crate::ImResult<ProtectedSecret> {
253        Err(self.unavailable())
254    }
255
256    fn unprotect(
257        &self,
258        _label: &PlatformSecretLabel,
259        _protected: &ProtectedSecret,
260    ) -> crate::ImResult<SecretBytes> {
261        Err(self.unavailable())
262    }
263
264    fn delete(&self, _label: &PlatformSecretLabel) -> crate::ImResult<()> {
265        Err(self.unavailable())
266    }
267}
268
269pub(crate) fn default_platform_protector() -> UnavailablePlatformProtector {
270    UnavailablePlatformProtector::new(
271        "platform-specific Keychain/Keystore/DPAPI/Secret Service backend is not wired yet",
272    )
273}
274
275pub(crate) fn protect_device_vault_root_key(
276    protector: &dyn PlatformProtector,
277    label: &PlatformSecretLabel,
278    root_key: &DeviceVaultRootKey,
279) -> crate::ImResult<ProtectedSecret> {
280    let secret = SecretBytes::from_vec(root_key.expose_secret().to_vec());
281    protector.protect(label, &secret)
282}
283
284pub(crate) fn unprotect_device_vault_root_key(
285    protector: &dyn PlatformProtector,
286    label: &PlatformSecretLabel,
287    protected: &ProtectedSecret,
288) -> crate::ImResult<DeviceVaultRootKey> {
289    let secret = protector.unprotect(label, protected)?;
290    DeviceVaultRootKey::try_from_secret(&secret)
291}
292
293#[cfg(test)]
294pub(crate) struct InMemoryPlatformProtector {
295    wrapping_key: [u8; DEVICE_VAULT_ROOT_KEY_LEN],
296    policy: PlatformProtectionPolicy,
297}
298
299#[cfg(test)]
300impl InMemoryPlatformProtector {
301    pub(crate) fn new_for_tests(wrapping_key: [u8; DEVICE_VAULT_ROOT_KEY_LEN]) -> Self {
302        Self {
303            wrapping_key,
304            policy: PlatformProtectionPolicy::no_prompt_current_user(),
305        }
306    }
307
308    fn aad(label: &PlatformSecretLabel) -> Vec<u8> {
309        format!("awiki:platform-secret:v1:{}", label.item_name()).into_bytes()
310    }
311}
312
313#[cfg(test)]
314impl PlatformProtector for InMemoryPlatformProtector {
315    fn protector_kind(&self) -> &'static str {
316        "test-memory-aead"
317    }
318
319    fn policy(&self) -> PlatformProtectionPolicy {
320        self.policy.clone()
321    }
322
323    fn protect(
324        &self,
325        label: &PlatformSecretLabel,
326        plaintext: &SecretBytes,
327    ) -> crate::ImResult<ProtectedSecret> {
328        self.policy.validate_no_prompt()?;
329        let mut nonce = [0_u8; MEMORY_PROTECTOR_NONCE_LEN];
330        OsRng.fill_bytes(&mut nonce);
331        let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.wrapping_key));
332        let mut blob = nonce.to_vec();
333        let ciphertext = cipher
334            .encrypt(
335                Nonce::from_slice(&nonce),
336                Payload {
337                    msg: plaintext.expose_secret(),
338                    aad: &Self::aad(label),
339                },
340            )
341            .map_err(|_| crate::ImError::Internal {
342                message: "platform secret protection failed".to_owned(),
343            })?;
344        blob.extend_from_slice(&ciphertext);
345        Ok(ProtectedSecret::new(
346            label.item_name(),
347            self.protector_kind(),
348            self.policy.clone(),
349            &blob,
350        ))
351    }
352
353    fn unprotect(
354        &self,
355        label: &PlatformSecretLabel,
356        protected: &ProtectedSecret,
357    ) -> crate::ImResult<SecretBytes> {
358        self.policy.validate_no_prompt()?;
359        if protected.schema_version != PLATFORM_PROTECTED_SECRET_SCHEMA_VERSION {
360            return Err(crate::ImError::Serialization {
361                detail: "unsupported platform protected secret schema version".to_owned(),
362            });
363        }
364        if protected.label != label.item_name() {
365            return Err(crate::ImError::PermissionDenied);
366        }
367        if protected.protector_kind != self.protector_kind() {
368            return Err(crate::ImError::unsupported(format!(
369                "unsupported platform secret protector {}",
370                protected.protector_kind
371            )));
372        }
373        protected.policy.validate_no_prompt()?;
374        let blob = protected.protected_blob()?;
375        if blob.len() <= MEMORY_PROTECTOR_NONCE_LEN {
376            return Err(crate::ImError::Serialization {
377                detail: "platform protected secret blob is too short".to_owned(),
378            });
379        }
380        let (nonce, ciphertext) = blob.split_at(MEMORY_PROTECTOR_NONCE_LEN);
381        let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.wrapping_key));
382        let plaintext = cipher
383            .decrypt(
384                Nonce::from_slice(nonce),
385                Payload {
386                    msg: ciphertext,
387                    aad: &Self::aad(label),
388                },
389            )
390            .map_err(|_| crate::ImError::PermissionDenied)?;
391        Ok(SecretBytes::from_vec(plaintext))
392    }
393
394    fn delete(&self, _label: &PlatformSecretLabel) -> crate::ImResult<()> {
395        Ok(())
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn unavailable_platform_protector_fails_without_plaintext_fallback() {
405        let protector = default_platform_protector();
406        let label = PlatformSecretLabel::vault_root_key("workspace", "device");
407        let secret = SecretBytes::from_vec(b"leaked-root-key".to_vec());
408
409        let err = protector.protect(&label, &secret).unwrap_err();
410
411        assert!(matches!(err, crate::ImError::UnsupportedCapability { .. }));
412        assert!(!format!("{err}").contains("leaked-root-key"));
413    }
414
415    #[test]
416    fn memory_platform_protector_roundtrips_no_prompt_root_key() {
417        let protector = InMemoryPlatformProtector::new_for_tests([7_u8; 32]);
418        let label = PlatformSecretLabel::vault_root_key("workspace", "device");
419        let root_key = DeviceVaultRootKey::from_bytes([9_u8; 32]);
420
421        let protected = protect_device_vault_root_key(&protector, &label, &root_key).unwrap();
422        let opened = unprotect_device_vault_root_key(&protector, &label, &protected).unwrap();
423
424        assert_eq!(opened.expose_secret(), root_key.expose_secret());
425        assert!(protected.policy.no_prompt);
426        assert!(!protected.policy.user_presence_required);
427    }
428
429    #[test]
430    fn memory_platform_protector_rejects_wrong_label() {
431        let protector = InMemoryPlatformProtector::new_for_tests([7_u8; 32]);
432        let label = PlatformSecretLabel::vault_root_key("workspace", "device-a");
433        let other_label = PlatformSecretLabel::vault_root_key("workspace", "device-b");
434        let root_key = DeviceVaultRootKey::from_bytes([9_u8; 32]);
435        let protected = protect_device_vault_root_key(&protector, &label, &root_key).unwrap();
436
437        let err =
438            unprotect_device_vault_root_key(&protector, &other_label, &protected).unwrap_err();
439
440        assert_eq!(err, crate::ImError::PermissionDenied);
441    }
442
443    #[test]
444    fn platform_secret_debug_redacts_secret_material() {
445        let protector = InMemoryPlatformProtector::new_for_tests([7_u8; 32]);
446        let label = PlatformSecretLabel::vault_root_key("workspace", "device");
447        let secret = SecretBytes::from_vec(b"debug-secret-value".to_vec());
448        let protected = protector.protect(&label, &secret).unwrap();
449
450        assert!(!format!("{secret:?}").contains("debug-secret-value"));
451        assert!(!format!("{protected:?}").contains(&protected.protected_blob_b64u));
452        assert!(format!("{protected:?}").contains("[REDACTED]"));
453    }
454}