im_core/internal/platform_secret/
mod.rs1use 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}