core_auth/
ssh.rs

1//! SSH-agent authentication backend.
2//!
3//! Connects to the user's SSH agent via `$SSH_AUTH_SOCK`, signs a
4//! deterministic BLAKE3 challenge with the enrolled key, derives a KEK
5//! from the signature, and unwraps the AES-256-GCM wrapped master key
6//! from the enrollment blob.
7
8use crate::AuthError;
9use crate::backend::{AuthInteraction, IpcUnlockStrategy, UnlockOutcome, VaultAuthBackend};
10use crate::ssh_types::{EnrollmentBlob, SshKeyType};
11use core_crypto::SecureBytes;
12use core_types::AuthFactorId;
13use core_types::TrustProfileName;
14use ssh_agent_client_rs::Identity;
15use std::collections::BTreeMap;
16use std::path::Path;
17use zeroize::Zeroize;
18
19/// Get the fingerprint string for an identity.
20fn identity_fingerprint(id: &Identity<'_>) -> String {
21    match id {
22        Identity::PublicKey(cow) => cow.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
23        Identity::Certificate(cow) => cow
24            .public_key()
25            .fingerprint(ssh_key::HashAlg::Sha256)
26            .to_string(),
27    }
28}
29
30/// Get the algorithm for an identity.
31fn identity_algorithm(id: &Identity<'_>) -> ssh_key::Algorithm {
32    match id {
33        Identity::PublicKey(cow) => cow.algorithm(),
34        Identity::Certificate(cow) => cow.algorithm(),
35    }
36}
37
38/// Connect to the SSH agent, trying multiple socket paths.
39///
40/// Resolution order:
41/// 1. `$SSH_AUTH_SOCK` (may be a symlink to a forwarded agent socket)
42/// 2. `~/.ssh/agent.sock` — stable symlink path created by the Konductor
43///    profile.d propagation script for forwarded SSH agent sessions
44///
45/// Returns `None` if no connectable agent socket is found.
46/// Intentionally synchronous — local Unix socket connect is sub-millisecond.
47fn connect_agent() -> Option<ssh_agent_client_rs::Client> {
48    // Primary: $SSH_AUTH_SOCK (set by ssh-agent, sshd forwarding, or systemd env)
49    if let Some(sock_path) = std::env::var_os("SSH_AUTH_SOCK") {
50        let path = std::path::Path::new(&sock_path);
51        if let Ok(client) = ssh_agent_client_rs::Client::connect(path) {
52            return Some(client);
53        }
54    }
55
56    // Fallback: well-known stable symlink managed by profile.d hook.
57    // On Konductor VMs, /etc/profile.d/konductor-ssh-agent.sh creates
58    // ~/.ssh/agent.sock -> /tmp/ssh-XXXX/agent.PID on each SSH login,
59    // giving systemd user services a stable path to the forwarded agent.
60    if let Some(home) = std::env::var_os("HOME") {
61        let fallback = std::path::Path::new(&home).join(".ssh/agent.sock");
62        if let Ok(client) = ssh_agent_client_rs::Client::connect(&fallback) {
63            return Some(client);
64        }
65    }
66
67    None
68}
69
70/// SSH-agent backed vault authentication.
71///
72/// Connects to `$SSH_AUTH_SOCK`, signs a BLAKE3-derived challenge with
73/// the enrolled key, derives a KEK from the deterministic signature,
74/// and unwraps the master key from the enrollment blob.
75pub struct SshAgentBackend;
76
77impl SshAgentBackend {
78    #[must_use]
79    pub fn new() -> Self {
80        Self
81    }
82
83    /// Path to the enrollment blob for a profile.
84    fn enrollment_path(config_dir: &Path, profile: &TrustProfileName) -> std::path::PathBuf {
85        config_dir
86            .join("vaults")
87            .join(format!("{profile}.ssh-enrollment"))
88    }
89}
90
91impl Default for SshAgentBackend {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97#[async_trait::async_trait]
98#[allow(clippy::unnecessary_literal_bound)]
99impl VaultAuthBackend for SshAgentBackend {
100    fn factor_id(&self) -> AuthFactorId {
101        AuthFactorId::SshAgent
102    }
103
104    fn name(&self) -> &str {
105        "SSH Agent"
106    }
107
108    fn backend_id(&self) -> &str {
109        "ssh-agent"
110    }
111
112    fn is_enrolled(&self, profile: &TrustProfileName, config_dir: &Path) -> bool {
113        Self::enrollment_path(config_dir, profile).exists()
114    }
115
116    async fn can_unlock(&self, profile: &TrustProfileName, config_dir: &Path) -> bool {
117        if !self.is_enrolled(profile, config_dir) {
118            return false;
119        }
120
121        let blob_path = Self::enrollment_path(config_dir, profile);
122        let Ok(blob_data) = std::fs::read(&blob_path) else {
123            return false;
124        };
125        let Ok(blob) = EnrollmentBlob::deserialize(&blob_data) else {
126            return false;
127        };
128
129        let fingerprint = blob.key_fingerprint.clone();
130        tokio::task::spawn_blocking(move || {
131            let Some(mut agent) = connect_agent() else {
132                return false;
133            };
134            let Ok(identities) = agent.list_all_identities() else {
135                return false;
136            };
137            identities
138                .iter()
139                .any(|id| identity_fingerprint(id) == fingerprint)
140        })
141        .await
142        .unwrap_or(false)
143    }
144
145    fn requires_interaction(&self) -> AuthInteraction {
146        AuthInteraction::None
147    }
148
149    async fn unlock(
150        &self,
151        profile: &TrustProfileName,
152        config_dir: &Path,
153        salt: &[u8],
154    ) -> Result<UnlockOutcome, AuthError> {
155        // 1. Read enrollment blob
156        let blob_path = Self::enrollment_path(config_dir, profile);
157        let blob_data =
158            std::fs::read(&blob_path).map_err(|_| AuthError::NotEnrolled(profile.to_string()))?;
159        let blob = EnrollmentBlob::deserialize(&blob_data)?;
160
161        // 2. Derive challenge
162        let profile_str = profile.to_string();
163        let challenge_ctx = format!("pds v2 ssh-challenge {profile_str}");
164        let challenge_bytes: [u8; 32] = blake3::derive_key(&challenge_ctx, salt);
165
166        // 3. Connect to agent, find enrolled key, sign challenge.
167        //    ssh-agent-client-rs is synchronous (Unix socket I/O), so all
168        //    agent calls run inside spawn_blocking to avoid blocking the
169        //    tokio runtime.
170        let fingerprint = blob.key_fingerprint.clone();
171        let challenge = challenge_bytes.to_vec();
172        let sign_result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AuthError> {
173            let mut agent = connect_agent().ok_or_else(|| {
174                AuthError::AgentUnavailable("SSH_AUTH_SOCK not set or agent not running".into())
175            })?;
176
177            let identities = agent.list_all_identities().map_err(|e| {
178                AuthError::AgentProtocolError(format!("failed to list identities: {e}"))
179            })?;
180
181            let identity = identities
182                .into_iter()
183                .find(|id| identity_fingerprint(id) == fingerprint)
184                .ok_or(AuthError::NoEligibleKey)?;
185
186            let signature = agent
187                .sign(identity, &challenge)
188                .map_err(|e| AuthError::AgentProtocolError(format!("sign request failed: {e}")))?;
189
190            // Immediately copy the signature bytes so the Vec is the only
191            // unprotected holder; zeroized promptly after KEK derivation below.
192            Ok(signature.as_bytes().to_vec())
193        })
194        .await
195        .map_err(|e| AuthError::AgentUnavailable(format!("spawn_blocking failed: {e}")))??;
196
197        // 4. Derive KEK from signature, then zeroize the raw signature bytes
198        //    immediately. The signature is the sole input to KEK derivation —
199        //    minimizing its lifetime reduces the exposure window.
200        let kek_ctx = format!("pds v2 ssh-vault-kek {profile_str}");
201        let mut kek_bytes: [u8; 32] = blake3::derive_key(&kek_ctx, &sign_result);
202        let mut sig_bytes = sign_result;
203        sig_bytes.zeroize();
204
205        // 5. Unwrap master key
206        let encryption_key = core_crypto::EncryptionKey::from_bytes(&kek_bytes)
207            .map_err(|_| AuthError::UnwrapFailed)?;
208        kek_bytes.zeroize();
209
210        let master_key_bytes = encryption_key
211            .decrypt(&blob.nonce, &blob.ciphertext)
212            .map_err(|_| AuthError::UnwrapFailed)?;
213
214        // 6. Build outcome
215        let mut audit_metadata = BTreeMap::new();
216        audit_metadata.insert("backend".into(), "ssh-agent".into());
217        audit_metadata.insert("ssh_fingerprint".into(), blob.key_fingerprint.clone());
218        audit_metadata.insert("key_type".into(), blob.key_type.wire_name().into());
219
220        Ok(UnlockOutcome {
221            master_key: master_key_bytes,
222            audit_metadata,
223            ipc_strategy: IpcUnlockStrategy::DirectMasterKey,
224            factor_id: AuthFactorId::SshAgent,
225        })
226    }
227
228    async fn enroll(
229        &self,
230        profile: &TrustProfileName,
231        master_key: &SecureBytes,
232        config_dir: &Path,
233        salt: &[u8],
234        selected_key_index: Option<usize>,
235    ) -> Result<(), AuthError> {
236        // 1. Connect to agent, list eligible keys, sign challenge
237        let profile_str = profile.to_string();
238        let challenge_ctx = format!("pds v2 ssh-challenge {profile_str}");
239        let challenge: [u8; 32] = blake3::derive_key(&challenge_ctx, salt);
240        let challenge_vec = challenge.to_vec();
241
242        // ssh-agent-client-rs is synchronous (Unix socket I/O), so all agent
243        // calls run inside spawn_blocking to avoid blocking the tokio runtime.
244        let kek_ctx = format!("pds v2 ssh-vault-kek {profile_str}");
245        let (fingerprint, key_type, mut kek_bytes) = tokio::task::spawn_blocking(
246            move || -> Result<(String, SshKeyType, [u8; 32]), AuthError> {
247                let mut agent = connect_agent().ok_or_else(|| {
248                    AuthError::AgentUnavailable("SSH_AUTH_SOCK not set or agent not running".into())
249                })?;
250
251                let identities = agent
252                    .list_all_identities()
253                    .map_err(|e| AuthError::AgentProtocolError(format!("list identities: {e}")))?;
254
255                let eligible: Vec<_> = identities
256                    .into_iter()
257                    .filter(|id| SshKeyType::from_algorithm(&identity_algorithm(id)).is_ok())
258                    .collect();
259
260                if eligible.is_empty() {
261                    return Err(AuthError::NoEligibleKey);
262                }
263
264                let idx = selected_key_index.ok_or(AuthError::NoEligibleKey)?;
265                if idx >= eligible.len() {
266                    return Err(AuthError::NoEligibleKey);
267                }
268                let identity = eligible.into_iter().nth(idx).unwrap();
269                let fp = identity_fingerprint(&identity);
270                let algo = identity_algorithm(&identity);
271                let kt = SshKeyType::from_algorithm(&algo)?;
272
273                let sig = agent
274                    .sign(identity, &challenge_vec)
275                    .map_err(|e| AuthError::AgentProtocolError(format!("sign: {e}")))?;
276
277                // Derive KEK immediately, zeroize signature, return only the KEK.
278                let mut sig_bytes = sig.as_bytes().to_vec();
279                let kek: [u8; 32] = blake3::derive_key(&kek_ctx, &sig_bytes);
280                sig_bytes.zeroize();
281
282                Ok((fp, kt, kek))
283            },
284        )
285        .await
286        .map_err(|e| AuthError::AgentUnavailable(format!("spawn_blocking: {e}")))??;
287
288        // 3. Wrap master key
289        let encryption_key = core_crypto::EncryptionKey::from_bytes(&kek_bytes)
290            .map_err(|_| AuthError::UnwrapFailed)?;
291        kek_bytes.zeroize();
292
293        let mut nonce_bytes = [0u8; 12];
294        getrandom::getrandom(&mut nonce_bytes)
295            .map_err(|e| AuthError::Io(std::io::Error::other(e)))?;
296
297        let ciphertext = encryption_key
298            .encrypt(&nonce_bytes, master_key.as_bytes())
299            .map_err(|_| AuthError::UnwrapFailed)?;
300
301        // 4. Write enrollment blob atomically
302        let blob = EnrollmentBlob {
303            version: crate::ENROLLMENT_VERSION,
304            key_fingerprint: fingerprint.clone(),
305            key_type,
306            nonce: nonce_bytes,
307            ciphertext,
308        };
309
310        let blob_path = Self::enrollment_path(config_dir, profile);
311        let vaults_dir = config_dir.join("vaults");
312        std::fs::create_dir_all(&vaults_dir)?;
313
314        let tmp_path = blob_path.with_extension("ssh-enrollment.tmp");
315        std::fs::write(&tmp_path, blob.serialize())?;
316
317        // Set restrictive permissions (owner-only) before rename. The blob
318        // contains an AES-256-GCM encrypted master key; do not rely on umask.
319        #[cfg(unix)]
320        {
321            use std::os::unix::fs::PermissionsExt;
322            std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
323        }
324
325        std::fs::rename(&tmp_path, &blob_path)?;
326
327        tracing::info!(
328            profile = %profile,
329            fingerprint = %fingerprint,
330            key_type = %key_type.wire_name(),
331            "SSH enrollment created"
332        );
333
334        Ok(())
335    }
336
337    async fn revoke(&self, profile: &TrustProfileName, config_dir: &Path) -> Result<(), AuthError> {
338        let path = Self::enrollment_path(config_dir, profile);
339        if path.exists() {
340            // Extract fingerprint for audit logging before deletion
341            let fingerprint = std::fs::read(&path)
342                .ok()
343                .and_then(|data| EnrollmentBlob::deserialize(&data).ok())
344                .map_or_else(|| "<unreadable>".into(), |blob| blob.key_fingerprint);
345
346            // Overwrite with zeros before deletion to prevent casual recovery
347            #[allow(clippy::cast_possible_truncation)]
348            let file_len = std::fs::metadata(&path)
349                .map(|m| m.len() as usize)
350                .unwrap_or(256);
351            let zeros = vec![0u8; file_len];
352            let _ = std::fs::write(&path, &zeros);
353            std::fs::remove_file(&path)?;
354
355            tracing::info!(
356                profile = %profile,
357                fingerprint = %fingerprint,
358                "SSH enrollment revoked"
359            );
360        }
361        Ok(())
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn test_profile() -> TrustProfileName {
370        TrustProfileName::try_from("test-profile").unwrap()
371    }
372
373    #[test]
374    fn is_enrolled_checks_file() {
375        let dir = tempfile::tempdir().unwrap();
376        let backend = SshAgentBackend::new();
377        let profile = test_profile();
378
379        assert!(!backend.is_enrolled(&profile, dir.path()));
380
381        let vaults = dir.path().join("vaults");
382        std::fs::create_dir_all(&vaults).unwrap();
383        std::fs::write(vaults.join("test-profile.ssh-enrollment"), b"blob").unwrap();
384
385        assert!(backend.is_enrolled(&profile, dir.path()));
386    }
387
388    #[tokio::test]
389    async fn can_unlock_false_when_not_enrolled() {
390        let backend = SshAgentBackend::new();
391        let profile = test_profile();
392        assert!(
393            !backend
394                .can_unlock(&profile, std::path::Path::new("/tmp"))
395                .await
396        );
397    }
398
399    #[tokio::test]
400    async fn unlock_fails_no_enrollment() {
401        let dir = tempfile::tempdir().unwrap();
402        let backend = SshAgentBackend::new();
403        let profile = test_profile();
404        let result = backend.unlock(&profile, dir.path(), &[0; 16]).await;
405        assert!(matches!(result, Err(AuthError::NotEnrolled(_))));
406    }
407
408    #[tokio::test]
409    async fn unlock_fails_invalid_blob() {
410        let dir = tempfile::tempdir().unwrap();
411        let backend = SshAgentBackend::new();
412        let profile = test_profile();
413
414        let vaults = dir.path().join("vaults");
415        std::fs::create_dir_all(&vaults).unwrap();
416        std::fs::write(vaults.join("test-profile.ssh-enrollment"), b"corrupt").unwrap();
417
418        let result = backend.unlock(&profile, dir.path(), &[0; 16]).await;
419        assert!(matches!(result, Err(AuthError::InvalidBlob(_))));
420    }
421
422    #[tokio::test]
423    async fn revoke_removes_blob() {
424        let dir = tempfile::tempdir().unwrap();
425        let backend = SshAgentBackend::new();
426        let profile = test_profile();
427
428        let vaults = dir.path().join("vaults");
429        std::fs::create_dir_all(&vaults).unwrap();
430        let blob_path = vaults.join("test-profile.ssh-enrollment");
431        std::fs::write(&blob_path, b"blob").unwrap();
432        assert!(blob_path.exists());
433
434        backend.revoke(&profile, dir.path()).await.unwrap();
435        assert!(!blob_path.exists());
436    }
437
438    #[tokio::test]
439    async fn revoke_noop_when_no_blob() {
440        let dir = tempfile::tempdir().unwrap();
441        let backend = SshAgentBackend::new();
442        let profile = test_profile();
443        backend.revoke(&profile, dir.path()).await.unwrap();
444    }
445
446    #[test]
447    fn requires_no_interaction() {
448        let backend = SshAgentBackend::new();
449        assert_eq!(backend.requires_interaction(), AuthInteraction::None);
450    }
451
452    #[test]
453    fn backend_id_is_ssh_agent() {
454        let backend = SshAgentBackend::new();
455        assert_eq!(backend.backend_id(), "ssh-agent");
456    }
457
458    /// Verify `connect_agent` handles nonexistent socket paths gracefully.
459    /// We cannot mutate env vars (crate forbids unsafe), so this exercises
460    /// the connect path with a known-bad socket via direct `Client::connect`.
461    #[test]
462    fn connect_to_nonexistent_socket_returns_error() {
463        let bad_path = std::path::Path::new("/tmp/nonexistent-ssh-agent-test.sock");
464        let result = ssh_agent_client_rs::Client::connect(bad_path);
465        assert!(result.is_err());
466    }
467
468    #[test]
469    fn challenge_is_deterministic() {
470        let salt = [0xAA; 16];
471        let ctx = "pds v2 ssh-challenge test-profile";
472        let c1: [u8; 32] = blake3::derive_key(ctx, &salt);
473        let c2: [u8; 32] = blake3::derive_key(ctx, &salt);
474        assert_eq!(c1, c2);
475    }
476
477    #[test]
478    fn different_profiles_produce_different_challenges() {
479        let salt = [0xAA; 16];
480        let c1: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge profile-a", &salt);
481        let c2: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge profile-b", &salt);
482        assert_ne!(c1, c2);
483    }
484
485    #[test]
486    fn different_salts_produce_different_challenges() {
487        let c1: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge test", &[0xAA; 16]);
488        let c2: [u8; 32] = blake3::derive_key("pds v2 ssh-challenge test", &[0xBB; 16]);
489        assert_ne!(c1, c2);
490    }
491
492    #[test]
493    fn kek_derivation_is_deterministic() {
494        let sig = [0x42u8; 64];
495        let ctx = "pds v2 ssh-vault-kek test-profile";
496        let k1: [u8; 32] = blake3::derive_key(ctx, &sig);
497        let k2: [u8; 32] = blake3::derive_key(ctx, &sig);
498        assert_eq!(k1, k2);
499    }
500
501    #[test]
502    fn enrollment_blob_crypto_round_trip() {
503        let master_key_data = vec![0x42u8; 32];
504
505        // Simulate enrollment: derive KEK, wrap master key
506        let sig_bytes = [0xDE; 64]; // simulated signature
507        let kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
508        let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
509        let nonce = [0x11u8; 12];
510        let ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
511
512        let blob = EnrollmentBlob {
513            version: crate::ENROLLMENT_VERSION,
514            key_fingerprint: "SHA256:test".into(),
515            key_type: SshKeyType::Ed25519,
516            nonce,
517            ciphertext: ciphertext.clone(),
518        };
519
520        // Serialize and deserialize
521        let data = blob.serialize();
522        let parsed = EnrollmentBlob::deserialize(&data).unwrap();
523
524        // Simulate unlock: same KEK, unwrap
525        let kek2: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
526        let enc_key2 = core_crypto::EncryptionKey::from_bytes(&kek2).unwrap();
527        let unwrapped = enc_key2.decrypt(&parsed.nonce, &parsed.ciphertext).unwrap();
528
529        assert_eq!(unwrapped.as_bytes(), &master_key_data);
530    }
531
532    #[test]
533    fn tampered_blob_fails_unwrap() {
534        let master_key_data = vec![0x42u8; 32];
535        let sig_bytes = [0xDE; 64];
536        let kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
537        let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
538        let nonce = [0x11u8; 12];
539        let mut ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
540
541        // Tamper with ciphertext
542        ciphertext[0] ^= 0x01;
543
544        let result = enc_key.decrypt(&nonce, &ciphertext);
545        assert!(result.is_err());
546    }
547
548    #[test]
549    fn wrong_kek_fails_unwrap() {
550        let master_key_data = vec![0x42u8; 32];
551        let sig_bytes = [0xDE; 64];
552        let kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &sig_bytes);
553        let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
554        let nonce = [0x11u8; 12];
555        let ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
556
557        // Use wrong KEK (different signature)
558        let wrong_sig = [0xAB; 64];
559        let wrong_kek: [u8; 32] = blake3::derive_key("pds v2 ssh-vault-kek test", &wrong_sig);
560        let wrong_enc_key = core_crypto::EncryptionKey::from_bytes(&wrong_kek).unwrap();
561        let result = wrong_enc_key.decrypt(&nonce, &ciphertext);
562        assert!(result.is_err());
563    }
564
565    /// Full enrollment + unlock cycle integration test (simulated agent).
566    ///
567    /// Exercises the complete crypto round-trip without a real SSH agent:
568    /// generate challenge, derive KEK from simulated signature, wrap master
569    /// key, write enrollment blob to disk, read it back, unwrap, and verify
570    /// the recovered master key matches the original.
571    #[test]
572    fn full_enrollment_unlock_round_trip() {
573        let dir = tempfile::tempdir().unwrap();
574        let profile = test_profile();
575        let salt = [0xBB; 16];
576        let master_key_data = vec![0x42u8; 32];
577        let simulated_sig = [0xAB; 64];
578
579        // --- Enrollment phase ---
580
581        // 1. Generate challenge (same as SshAgentBackend::enroll)
582        let challenge_ctx = format!("pds v2 ssh-challenge {profile}");
583        let challenge: [u8; 32] = blake3::derive_key(&challenge_ctx, &salt);
584
585        // 2. Derive KEK from simulated signature
586        let kek_ctx = format!("pds v2 ssh-vault-kek {profile}");
587        let kek: [u8; 32] = blake3::derive_key(&kek_ctx, &simulated_sig);
588
589        // 3. Wrap master key
590        let enc_key = core_crypto::EncryptionKey::from_bytes(&kek).unwrap();
591        let nonce = [0x33u8; 12];
592        let ciphertext = enc_key.encrypt(&nonce, &master_key_data).unwrap();
593
594        // 4. Build and write enrollment blob
595        let blob = EnrollmentBlob {
596            version: crate::ENROLLMENT_VERSION,
597            key_fingerprint: "SHA256:test-round-trip".into(),
598            key_type: SshKeyType::Ed25519,
599            nonce,
600            ciphertext,
601        };
602        let vaults_dir = dir.path().join("vaults");
603        std::fs::create_dir_all(&vaults_dir).unwrap();
604        let blob_path = vaults_dir.join(format!("{profile}.ssh-enrollment"));
605        std::fs::write(&blob_path, blob.serialize()).unwrap();
606
607        // --- Unlock phase ---
608
609        // 5. Read blob back from disk
610        let blob_data = std::fs::read(&blob_path).unwrap();
611        let parsed = EnrollmentBlob::deserialize(&blob_data).unwrap();
612        assert_eq!(parsed.key_fingerprint, "SHA256:test-round-trip");
613        assert_eq!(parsed.key_type, SshKeyType::Ed25519);
614
615        // 6. Re-derive the same challenge (deterministic)
616        let challenge2: [u8; 32] = blake3::derive_key(&challenge_ctx, &salt);
617        assert_eq!(challenge, challenge2);
618
619        // 7. Same signature -> same KEK -> successful unwrap
620        let kek2: [u8; 32] = blake3::derive_key(&kek_ctx, &simulated_sig);
621        assert_eq!(kek, kek2);
622        let enc_key2 = core_crypto::EncryptionKey::from_bytes(&kek2).unwrap();
623        let unwrapped = enc_key2.decrypt(&parsed.nonce, &parsed.ciphertext).unwrap();
624
625        // 8. Verify recovered master key matches original
626        assert_eq!(unwrapped.as_bytes(), &master_key_data);
627    }
628}