craton-hsm 0.10.5

A production-grade PKCS#11 v3.0-compliant Software HSM written in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Craton Software Company
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use std::fs::File;
use std::path::PathBuf;

use zeroize::Zeroizing;

use crate::crypto::drbg::HmacDrbg;
use crate::error::{HsmError, HsmResult};

/// PBKDF2 iteration count per OWASP 2024 guidance (minimum 1,000,000).
const PBKDF2_ITERATIONS: u32 = 1_000_000;
const SALT_LEN: usize = 32;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;

/// redb table definition for encrypted object blobs.
/// Key: UTF-8 string (object identifier), Value: nonce || ciphertext bytes.
const OBJECTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("objects");

/// Encrypted persistent store backed by redb.
///
/// Objects are encrypted with AES-256-GCM using a key derived from
/// the user PIN via PBKDF2-HMAC-SHA256. The database file is protected
/// by an exclusive file lock (via `fs2`) to prevent concurrent access
/// from multiple processes.
pub struct EncryptedStore {
    db: Option<Database>,
    /// Hold the lock file open for the lifetime of the store.
    /// The exclusive lock is released when this file handle is dropped.
    _lock_file: Option<File>,
}

impl Drop for EncryptedStore {
    fn drop(&mut self) {
        // Release the exclusive lock by dropping the file handle.
        // We intentionally do NOT delete the lock file — removing it after
        // releasing the lock creates a TOCTOU race where another process
        // could acquire the (about-to-be-deleted) lock, only to have it
        // deleted out from under it, allowing a third process in.
        // The lock file is harmless on disk and will be reused next time.
        self._lock_file.take();
    }
}

impl EncryptedStore {
    /// Create a new encrypted store. If path is None, operates in memory-only mode.
    ///
    /// When a path is provided, acquires an exclusive file lock on
    /// `<path>.lock` to prevent concurrent access from another process.
    /// Returns an error if the database is already locked.
    pub fn new(path: Option<&str>) -> HsmResult<Self> {
        match path {
            Some(p) => {
                // Acquire exclusive file lock before opening the database
                let lock_path = PathBuf::from(format!("{}.lock", p));
                let lock_file = File::create(&lock_path).map_err(|e| {
                    tracing::error!(
                        "Failed to create lock file '{}': {}",
                        lock_path.display(),
                        e
                    );
                    HsmError::GeneralError
                })?;

                // Set restrictive permissions on the lock file to prevent
                // other users from observing HSM operation timing.
                // Failure here is fatal — see set_restrictive_permissions docs.
                set_restrictive_permissions(&lock_path)?;

                use fs2::FileExt;
                lock_file.try_lock_exclusive().map_err(|e| {
                    tracing::error!("Database at '{}' is locked by another process: {}", p, e);
                    HsmError::GeneralError
                })?;

                let db = Database::create(p).map_err(|e| {
                    tracing::error!("Failed to open database at '{}': {}", p, e);
                    HsmError::GeneralError
                })?;

                // Set restrictive permissions on the database file itself.
                // Failure here is fatal — refuse to open a world-readable HSM DB.
                set_restrictive_permissions(&PathBuf::from(p))?;

                Ok(Self {
                    db: Some(db),
                    _lock_file: Some(lock_file),
                })
            }
            None => Ok(Self {
                db: None,
                _lock_file: None,
            }),
        }
    }

    /// Check if persistent storage is available
    pub fn is_available(&self) -> bool {
        self.db.is_some()
    }

    /// Store an encrypted blob under a key
    pub fn store_encrypted(
        &self,
        store_key: &str,
        plaintext: &[u8],
        encryption_key: &[u8; KEY_LEN],
    ) -> HsmResult<()> {
        let db = self.db.as_ref().ok_or(HsmError::GeneralError)?;

        let mut nonce_bytes = [0u8; NONCE_LEN];
        // Route nonce generation through DRBG for health testing & prediction resistance,
        // consistent with all other randomness in the HSM (see drbg.rs architecture).
        let mut drbg = HmacDrbg::new()?;
        drbg.generate(&mut nonce_bytes)?;

        let aes_key = Key::<Aes256Gcm>::from_slice(encryption_key);
        let cipher = Aes256Gcm::new(aes_key);
        let nonce = Nonce::from_slice(&nonce_bytes);

        let ciphertext = cipher.encrypt(nonce, plaintext).map_err(|e| {
            tracing::error!("AES-GCM encryption failed for key '{}': {}", store_key, e);
            HsmError::GeneralError
        })?;

        // Store as: nonce || ciphertext
        let mut stored = Vec::with_capacity(NONCE_LEN + ciphertext.len());
        stored.extend_from_slice(&nonce_bytes);
        stored.extend_from_slice(&ciphertext);

        let write_txn = db.begin_write().map_err(|e| {
            tracing::error!("Failed to begin write transaction: {}", e);
            HsmError::GeneralError
        })?;
        {
            let mut table = write_txn.open_table(OBJECTS_TABLE).map_err(|e| {
                tracing::error!("Failed to open objects table for write: {}", e);
                HsmError::GeneralError
            })?;
            table.insert(store_key, stored.as_slice()).map_err(|e| {
                tracing::error!("Failed to insert key '{}': {}", store_key, e);
                HsmError::GeneralError
            })?;
        }
        write_txn.commit().map_err(|e| {
            tracing::error!("Failed to commit write transaction: {}", e);
            HsmError::GeneralError
        })?;

        Ok(())
    }

    /// Store several encrypted blobs under a **single** redb transaction.
    ///
    /// `redb` issues an `fsync` on every `commit()`, so writing N objects with
    /// N calls to [`EncryptedStore::store_encrypted`] costs N `fsync`s — the
    /// dominant cost of object creation by a wide margin. Every caller that
    /// already knows it is writing more than one object should use this
    /// instead; `C_GenerateKeyPair` alone halves its persistence cost by
    /// committing the public and private object together.
    ///
    /// # Atomicity
    ///
    /// This is *stronger* than a loop over `store_encrypted`, not weaker: the
    /// batch commits atomically, so a crash cannot leave a key pair
    /// half-persisted with the private half missing. Durability is unchanged —
    /// the single `commit()` still `fsync`s before returning.
    ///
    /// Each blob gets its own freshly generated nonce, exactly as in the
    /// single-blob path.
    /// Plaintexts stay wrapped in `Zeroizing` all the way in, so a batch of
    /// serialized key material is never copied into an unzeroized buffer.
    pub fn store_encrypted_batch(
        &self,
        items: &[(String, Zeroizing<Vec<u8>>)],
        encryption_key: &[u8; KEY_LEN],
    ) -> HsmResult<()> {
        if items.is_empty() {
            return Ok(());
        }
        let db = self.db.as_ref().ok_or(HsmError::GeneralError)?;

        let aes_key = Key::<Aes256Gcm>::from_slice(encryption_key);
        let cipher = Aes256Gcm::new(aes_key);

        // Encrypt everything before opening the transaction so the write lock
        // is held only for the duration of the inserts.
        let mut sealed: Vec<(&str, Vec<u8>)> = Vec::with_capacity(items.len());
        let mut drbg = HmacDrbg::new()?;
        for (store_key, plaintext) in items {
            let mut nonce_bytes = [0u8; NONCE_LEN];
            drbg.generate(&mut nonce_bytes)?;
            let nonce = Nonce::from_slice(&nonce_bytes);
            let ciphertext = cipher.encrypt(nonce, plaintext.as_slice()).map_err(|e| {
                tracing::error!("AES-GCM encryption failed for key '{}': {}", store_key, e);
                HsmError::GeneralError
            })?;
            let mut stored = Vec::with_capacity(NONCE_LEN + ciphertext.len());
            stored.extend_from_slice(&nonce_bytes);
            stored.extend_from_slice(&ciphertext);
            sealed.push((store_key.as_str(), stored));
        }

        let write_txn = db.begin_write().map_err(|e| {
            tracing::error!("Failed to begin batch write transaction: {}", e);
            HsmError::GeneralError
        })?;
        {
            let mut table = write_txn.open_table(OBJECTS_TABLE).map_err(|e| {
                tracing::error!("Failed to open objects table for batch write: {}", e);
                HsmError::GeneralError
            })?;
            for (store_key, stored) in &sealed {
                table.insert(*store_key, stored.as_slice()).map_err(|e| {
                    tracing::error!("Failed to insert key '{}': {}", store_key, e);
                    HsmError::GeneralError
                })?;
            }
        }
        write_txn.commit().map_err(|e| {
            tracing::error!("Failed to commit batch write transaction: {}", e);
            HsmError::GeneralError
        })?;

        Ok(())
    }

    /// Load and decrypt a blob.
    ///
    /// The returned buffer is wrapped in `Zeroizing` so that decrypted
    /// plaintext (which may contain key material) is automatically zeroed
    /// when dropped — callers no longer need to remember to zeroize manually.
    pub fn load_encrypted(
        &self,
        store_key: &str,
        encryption_key: &[u8; KEY_LEN],
    ) -> HsmResult<Option<Zeroizing<Vec<u8>>>> {
        let db = self.db.as_ref().ok_or(HsmError::GeneralError)?;

        let read_txn = db.begin_read().map_err(|e| {
            tracing::error!("Failed to begin read transaction: {}", e);
            HsmError::GeneralError
        })?;

        // The table may not exist yet if nothing has been stored
        let table = match read_txn.open_table(OBJECTS_TABLE) {
            Ok(t) => t,
            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
            Err(e) => {
                tracing::error!("Failed to open objects table for read: {}", e);
                return Err(HsmError::GeneralError);
            }
        };

        let stored = match table.get(store_key).map_err(|e| {
            tracing::error!("Failed to get key '{}': {}", store_key, e);
            HsmError::GeneralError
        })? {
            Some(data) => data.value().to_vec(),
            None => return Ok(None),
        };

        if stored.len() < NONCE_LEN {
            return Err(HsmError::EncryptedDataInvalid);
        }

        let nonce = Nonce::from_slice(&stored[..NONCE_LEN]);
        let ciphertext = &stored[NONCE_LEN..];

        let aes_key = Key::<Aes256Gcm>::from_slice(encryption_key);
        let cipher = Aes256Gcm::new(aes_key);

        let plaintext = cipher
            .decrypt(nonce, ciphertext)
            .map_err(|_| HsmError::EncryptedDataInvalid)?;

        Ok(Some(Zeroizing::new(plaintext)))
    }

    /// Delete a stored key
    pub fn delete(&self, store_key: &str) -> HsmResult<()> {
        let db = self.db.as_ref().ok_or(HsmError::GeneralError)?;

        let write_txn = db.begin_write().map_err(|e| {
            tracing::error!("Failed to begin write transaction for delete: {}", e);
            HsmError::GeneralError
        })?;
        {
            let mut table = write_txn.open_table(OBJECTS_TABLE).map_err(|e| {
                tracing::error!("Failed to open objects table for delete: {}", e);
                HsmError::GeneralError
            })?;
            table.remove(store_key).map_err(|e| {
                tracing::error!("Failed to remove key '{}': {}", store_key, e);
                HsmError::GeneralError
            })?;
        }
        write_txn.commit().map_err(|e| {
            tracing::error!("Failed to commit delete transaction: {}", e);
            HsmError::GeneralError
        })?;
        Ok(())
    }

    /// List all keys in the store
    pub fn list_keys(&self) -> HsmResult<Vec<String>> {
        let db = self.db.as_ref().ok_or(HsmError::GeneralError)?;

        let read_txn = db.begin_read().map_err(|e| {
            tracing::error!("Failed to begin read transaction for list_keys: {}", e);
            HsmError::GeneralError
        })?;

        let table = match read_txn.open_table(OBJECTS_TABLE) {
            Ok(t) => t,
            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
            Err(e) => {
                tracing::error!("Failed to open objects table for list_keys: {}", e);
                return Err(HsmError::GeneralError);
            }
        };

        let mut keys = Vec::new();
        let iter = table.iter().map_err(|e| {
            tracing::error!("Failed to iterate objects table: {}", e);
            HsmError::GeneralError
        })?;
        for entry in iter.flatten() {
            keys.push(entry.0.value().to_string());
        }
        Ok(keys)
    }

    /// Clear all data from the store (used by C_InitToken)
    pub fn clear(&self) -> HsmResult<()> {
        let db = self.db.as_ref().ok_or(HsmError::GeneralError)?;

        let write_txn = db.begin_write().map_err(|e| {
            tracing::error!("Failed to begin write transaction for clear: {}", e);
            HsmError::GeneralError
        })?;
        {
            // Delete the entire table. A new one will be created on next write.
            let _ = write_txn.delete_table(OBJECTS_TABLE);
        }
        write_txn.commit().map_err(|e| {
            tracing::error!("Failed to commit clear transaction: {}", e);
            HsmError::GeneralError
        })?;
        Ok(())
    }
}

/// Set restrictive file permissions (owner-only read/write) on a path.
/// On Unix, sets mode 0o600. On Windows, sets a DACL granting only the
/// current user GENERIC_ALL access (removing inherited ACEs).
///
/// Failures are treated as **fatal**: a permission setting that silently
/// no-ops would leave HSM-managed files (database, lock, replay guard)
/// readable to other local users — exactly the threat this function
/// exists to defend against. Callers that need to tolerate environments
/// where permission changes cannot succeed (e.g., constrained containers,
/// network filesystems without ACL support) should explicitly opt into
/// the permissive path via [`set_restrictive_permissions_or_warn`].
pub fn set_restrictive_permissions(path: &std::path::Path) -> HsmResult<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|e| {
            tracing::error!(
                "Failed to set restrictive permissions on '{}': {} — refusing to continue with \
                 a potentially world-readable HSM file. Set explicit permissive mode if your \
                 environment cannot honor 0o600.",
                path.display(),
                e
            );
            HsmError::GeneralError
        })?;
        Ok(())
    }
    #[cfg(windows)]
    {
        set_restrictive_permissions_windows(path)
    }
    #[cfg(not(any(unix, windows)))]
    {
        // No supported permission model on this target; refuse rather than
        // silently leave the file world-accessible.
        tracing::error!(
            "No restrictive-permission backend for this target; cannot protect '{}'",
            path.display()
        );
        Err(HsmError::GeneralError)
    }
}

/// Permissive variant of [`set_restrictive_permissions`]. Logs a warning
/// on failure and continues. Use **only** when the caller has made an
/// informed choice that running with potentially loose permissions is
/// acceptable (e.g., test harnesses, opt-in deployment configurations).
#[allow(dead_code)]
pub fn set_restrictive_permissions_or_warn(path: &std::path::Path) {
    if let Err(_e) = set_restrictive_permissions(path) {
        tracing::warn!(
            "Continuing despite failure to restrict permissions on '{}' — file may be \
             accessible to other local users",
            path.display()
        );
    }
}

/// Windows implementation: set a DACL that grants only the current user
/// GENERIC_ALL, removing any inherited permissions from parent directories.
///
/// Returns `Err(HsmError::GeneralError)` on any FFI failure — silently
/// succeeding here would leave the file with whatever inherited ACEs the
/// parent directory has, which on a multi-user Windows host can include
/// `Users` or `Authenticated Users` reads.
#[cfg(windows)]
#[allow(unsafe_code)]
fn set_restrictive_permissions_windows(path: &std::path::Path) -> HsmResult<()> {
    use std::os::windows::ffi::OsStrExt;

    // Convert path to null-terminated wide string
    let wide_path: Vec<u16> = path
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();

    // SAFETY: All FFI calls operate on valid handles and buffers with lengths
    // checked before use. The token handle is closed in all code paths.
    unsafe {
        use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, GENERIC_ALL};
        use windows_sys::Win32::Security::Authorization::{
            SetEntriesInAclW, SetNamedSecurityInfoW, EXPLICIT_ACCESS_W, SET_ACCESS, SE_FILE_OBJECT,
            TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
        };
        use windows_sys::Win32::Security::{
            GetTokenInformation, TokenUser, ACL, DACL_SECURITY_INFORMATION, NO_INHERITANCE,
            PROTECTED_DACL_SECURITY_INFORMATION, PSID, TOKEN_QUERY, TOKEN_USER,
        };
        use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

        // Get current user's SID from the process token
        let mut token_handle = std::ptr::null_mut();
        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle) == 0 {
            tracing::error!(
                "Failed to open process token for ACL setup on '{}'",
                path.display()
            );
            return Err(HsmError::GeneralError);
        }

        // Query token user info size
        let mut needed: u32 = 0;
        GetTokenInformation(
            token_handle,
            TokenUser,
            std::ptr::null_mut(),
            0,
            &mut needed,
        );
        if needed == 0 {
            CloseHandle(token_handle);
            tracing::error!("Failed to query token user size for '{}'", path.display());
            return Err(HsmError::GeneralError);
        }

        let mut token_buf: Vec<u8> = vec![0u8; needed as usize];
        if GetTokenInformation(
            token_handle,
            TokenUser,
            token_buf.as_mut_ptr() as *mut _,
            needed,
            &mut needed,
        ) == 0
        {
            CloseHandle(token_handle);
            tracing::error!("Failed to get token user info for '{}'", path.display());
            return Err(HsmError::GeneralError);
        }
        CloseHandle(token_handle);

        let token_user = &*(token_buf.as_ptr() as *const TOKEN_USER);
        let user_sid: PSID = token_user.User.Sid;

        // Build an EXPLICIT_ACCESS entry granting only the current user GENERIC_ALL
        let mut ea: EXPLICIT_ACCESS_W = std::mem::zeroed();
        ea.grfAccessPermissions = GENERIC_ALL;
        ea.grfAccessMode = SET_ACCESS;
        ea.grfInheritance = NO_INHERITANCE;
        ea.Trustee = TRUSTEE_W {
            pMultipleTrustee: std::ptr::null_mut(),
            MultipleTrusteeOperation: 0,
            TrusteeForm: TRUSTEE_IS_SID,
            TrusteeType: TRUSTEE_IS_USER,
            ptstrName: user_sid as *mut u16,
        };

        // Create the new ACL
        let mut new_acl: *mut ACL = std::ptr::null_mut();
        let result = SetEntriesInAclW(1, &ea, std::ptr::null(), &mut new_acl);
        if result != 0 {
            tracing::error!(
                "SetEntriesInAclW failed ({}) for '{}'",
                result,
                path.display()
            );
            return Err(HsmError::GeneralError);
        }

        // Apply the DACL to the file with PROTECTED flag to block inheritance
        let result = SetNamedSecurityInfoW(
            wide_path.as_ptr(),
            SE_FILE_OBJECT,
            DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            new_acl,
            std::ptr::null_mut(),
        );
        let apply_err = if result != 0 {
            tracing::error!(
                "SetNamedSecurityInfoW failed ({}) for '{}'",
                result,
                path.display()
            );
            Some(HsmError::GeneralError)
        } else {
            None
        };

        if !new_acl.is_null() {
            LocalFree(new_acl as *mut _);
        }

        match apply_err {
            Some(e) => Err(e),
            None => Ok(()),
        }
    }
}

/// Wrap a 32-byte key with a key-encryption key (KEK) using AES-256-GCM.
///
/// Returns `nonce (12 bytes) || ciphertext (32 + 16-byte tag)`. Used to
/// protect the object master key with the PIN-derived key: the master key
/// (and therefore all object ciphertext) stays stable across PIN changes —
/// only the small wrapping blob is re-computed on `C_SetPIN`, so no bulk
/// re-encryption is required and a PIN change is atomic.
pub fn wrap_key(kek: &[u8; KEY_LEN], key: &[u8; KEY_LEN]) -> HsmResult<Vec<u8>> {
    let mut nonce_bytes = [0u8; NONCE_LEN];
    let mut drbg = HmacDrbg::new()?;
    drbg.generate(&mut nonce_bytes)?;

    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
    let nonce = Nonce::from_slice(&nonce_bytes);
    let ciphertext = cipher
        .encrypt(nonce, key.as_slice())
        .map_err(|_| HsmError::GeneralError)?;

    let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
    out.extend_from_slice(&nonce_bytes);
    out.extend_from_slice(&ciphertext);
    Ok(out)
}

/// Unwrap a 32-byte key previously produced by [`wrap_key`].
///
/// Returns the unwrapped key in a `Zeroizing` container. An authentication
/// failure (wrong KEK, i.e. wrong PIN, or tampering) returns
/// `EncryptedDataInvalid`.
pub fn unwrap_key(kek: &[u8; KEY_LEN], wrapped: &[u8]) -> HsmResult<Zeroizing<[u8; KEY_LEN]>> {
    if wrapped.len() < NONCE_LEN {
        return Err(HsmError::EncryptedDataInvalid);
    }
    let nonce = Nonce::from_slice(&wrapped[..NONCE_LEN]);
    let ciphertext = &wrapped[NONCE_LEN..];

    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
    let mut plaintext = cipher
        .decrypt(nonce, ciphertext)
        .map_err(|_| HsmError::EncryptedDataInvalid)?;

    if plaintext.len() != KEY_LEN {
        use zeroize::Zeroize;
        plaintext.zeroize();
        return Err(HsmError::EncryptedDataInvalid);
    }
    let mut out = Zeroizing::new([0u8; KEY_LEN]);
    out.copy_from_slice(&plaintext);
    // Zeroize the intermediate plaintext Vec; `out` retains the only copy.
    use zeroize::Zeroize;
    plaintext.zeroize();
    Ok(out)
}

/// Derive an encryption key from a PIN using PBKDF2-HMAC-SHA256.
/// Returns (derived_key, salt). If salt is None, generates a new random salt.
/// The derived key is wrapped in `Zeroizing` so it is cleared on drop.
///
/// `iterations` controls the PBKDF2 work factor. Pass the value from
/// `HsmConfig::security.pbkdf2_iterations` so that runtime config is honored.
/// Falls back to `PBKDF2_ITERATIONS` if `None`.
pub fn derive_key_from_pin(
    pin: &[u8],
    salt: Option<&[u8]>,
    iterations: Option<u32>,
) -> (Zeroizing<[u8; KEY_LEN]>, Vec<u8>) {
    let salt_bytes = if let Some(s) = salt {
        s.to_vec()
    } else {
        let mut s = vec![0u8; SALT_LEN];
        // Route through DRBG for health testing & prediction resistance
        if let Ok(mut drbg) = HmacDrbg::new() {
            let _ = drbg.generate(&mut s);
        } else {
            // Fallback to OsRng if DRBG instantiation fails (should not happen)
            use rand::rngs::OsRng;
            use rand::RngCore;
            OsRng.fill_bytes(&mut s);
        }
        s
    };

    let iters = iterations.unwrap_or(PBKDF2_ITERATIONS);
    let mut derived_key = Zeroizing::new([0u8; KEY_LEN]);
    pbkdf2::pbkdf2_hmac::<sha2::Sha256>(pin, &salt_bytes, iters, derived_key.as_mut());

    (derived_key, salt_bytes)
}

/// Verify a PIN against a stored PBKDF2 hash.
/// The stored_hash format is: salt (32 bytes) || derived_key (32 bytes)
pub fn verify_pin_pbkdf2(stored_hash: &[u8], pin: &[u8], iterations: Option<u32>) -> bool {
    if stored_hash.len() != SALT_LEN + KEY_LEN {
        return false;
    }
    let salt = &stored_hash[..SALT_LEN];
    let stored_key = &stored_hash[SALT_LEN..];

    let (derived_key, _) = derive_key_from_pin(pin, Some(salt), iterations);

    use subtle::ConstantTimeEq;
    stored_key.ct_eq(derived_key.as_ref()).into()
}

/// Hash a PIN for storage using PBKDF2.
/// Returns `Zeroizing<Vec<u8>>` containing salt (32 bytes) || derived_key (32 bytes).
/// The wrapper ensures the derived key bytes are zeroized when dropped.
pub fn hash_pin_pbkdf2(pin: &[u8], iterations: Option<u32>) -> Zeroizing<Vec<u8>> {
    let (derived_key, salt) = derive_key_from_pin(pin, None, iterations);
    let mut result = Vec::with_capacity(SALT_LEN + KEY_LEN);
    result.extend_from_slice(&salt);
    result.extend_from_slice(derived_key.as_ref());
    Zeroizing::new(result)
}