ndn_security/
file_tpm.rs

1//! File-backed TPM (private-key store), wire-compatible with
2//! `ndn-cxx`'s `tpm-file` backend (path B + Ed25519 superset).
3//!
4//! # Compatibility model
5//!
6//! The on-disk format for **RSA** and **ECDSA-P256** keys is bit-for-bit
7//! compatible with `ndnsec`. An ndn-rs binary writing an RSA or ECDSA
8//! key under this TPM produces a file `ndnsec key-list` and `ndnsec sign`
9//! can read, and vice versa. Pinned to ndn-cxx tag `ndn-cxx-0.9.0`,
10//! commit `0751bba8`, file `ndn-cxx/security/tpm/impl/back-end-file.cpp`
11//! (lines 51–229).
12//!
13//! Ed25519 is **not** supported by ndn-cxx `tpm-file` — its
14//! `d2i_AutoPrivateKey` path only autodetects RSA and EC from ASN.1
15//! tags, and `BackEndFile::createKey` rejects anything else
16//! (`back-end-file.cpp:130-139`). To preserve Ed25519 as a first-class
17//! algorithm in ndn-rs without breaking ndn-cxx interop, this module
18//! stores Ed25519 keys with a sentinel filename suffix:
19//!
20//! - `<HEX>.privkey`          → RSA / ECDSA, exactly as ndn-cxx writes
21//! - `<HEX>.privkey-ed25519`  → ndn-rs Ed25519 PKCS#8, ignored by ndnsec
22//!
23//! ndn-cxx's loader only opens `*.privkey` files and silently ignores
24//! the sentinel suffix; ndn-rs reads both. This is "path B" in the
25//! design discussion: superset compatibility, not strict.
26//!
27//! # Storage rules (MUST match ndn-cxx for `.privkey` files)
28//!
29//! - **Directory**: `$HOME/.ndn/ndnsec-key-file/`. Honours `TEST_HOME`
30//!   first, then `HOME`, then CWD. Created with `0o700` (ndn-cxx omits
31//!   the explicit chmod but inherits umask; we set it explicitly because
32//!   it's the right thing).
33//! - **Filename**: `hex(SHA256(key_name.wire_encode())).to_uppercase()`
34//!   plus `.privkey` (or `.privkey-ed25519` for Ed25519). The hash input
35//!   is the **TLV wire encoding** of the Name (outer type 0x07 + length
36//!   + components), not the URI string. Easy to get wrong; the test
37//!     `filename_matches_known_hash` asserts the format.
38//! - **File body**: base64 of the raw private-key DER, no PEM armor, no
39//!   header, no encryption.
40//!     - RSA → PKCS#1 `RSAPrivateKey` DER
41//!     - ECDSA-P256 → SEC1 `ECPrivateKey` DER
42//!     - Ed25519 (sentinel) → PKCS#8 `PrivateKeyInfo` DER
43//! - **Permissions**: per-file `chmod 0o400` on save (read-only by
44//!   owner, no write even by owner). `back-end-file.cpp:228`.
45//!
46//! Public-key recovery is on demand from the loaded private key — there
47//! are no separate public-key files; the PIB references the public
48//! material via `key_bits` BLOBs.
49
50use std::fs;
51use std::os::unix::fs::PermissionsExt;
52use std::path::{Path, PathBuf};
53
54use bytes::Bytes;
55use ndn_packet::{Name, tlv_type};
56use ndn_tlv::TlvWriter;
57use sha2::{Digest, Sha256};
58
59use crate::TrustError;
60
61// ─── Errors ───────────────────────────────────────────────────────────────────
62
63/// Errors returned by `FileTpm` operations. Mapped to `TrustError` at the
64/// public boundary so callers don't need to depend on this module's type.
65#[derive(Debug, thiserror::Error)]
66pub enum FileTpmError {
67    #[error("I/O error: {0}")]
68    Io(#[from] std::io::Error),
69    #[error("key not found: {0}")]
70    KeyNotFound(String),
71    #[error("invalid key encoding: {0}")]
72    InvalidKey(String),
73    #[error("base64 decode error: {0}")]
74    Base64(String),
75    #[error("unsupported algorithm in tpm-file: {0}")]
76    UnsupportedAlgorithm(String),
77    #[error("signing error: {0}")]
78    Sign(String),
79}
80
81impl From<FileTpmError> for TrustError {
82    fn from(e: FileTpmError) -> Self {
83        TrustError::KeyStore(e.to_string())
84    }
85}
86
87// ─── Algorithm tag ────────────────────────────────────────────────────────────
88
89/// Algorithm of a key stored in the TPM. Determined by file suffix and
90/// (for `.privkey` files) by ASN.1 autodetection of the inner DER.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum TpmKeyKind {
93    /// PKCS#1 `RSAPrivateKey` DER. Filename ends `.privkey`. Compatible
94    /// with ndn-cxx `tpm-file`.
95    Rsa,
96    /// SEC1 `ECPrivateKey` DER for the NIST P-256 curve. Filename ends
97    /// `.privkey`. Compatible with ndn-cxx `tpm-file`.
98    EcdsaP256,
99    /// PKCS#8 `PrivateKeyInfo` DER. Filename ends `.privkey-ed25519`.
100    /// **Not** loaded by ndn-cxx `tpm-file`; ndn-rs reads it via the
101    /// sentinel suffix. See module docs for the rationale.
102    Ed25519,
103}
104
105impl TpmKeyKind {
106    fn extension(self) -> &'static str {
107        match self {
108            TpmKeyKind::Rsa | TpmKeyKind::EcdsaP256 => "privkey",
109            TpmKeyKind::Ed25519 => "privkey-ed25519",
110        }
111    }
112}
113
114// ─── Path / filename derivation ──────────────────────────────────────────────
115
116/// Encode a Name to its canonical TLV wire form, the byte sequence that
117/// is hashed to produce the on-disk filename.
118fn name_wire_encode(name: &Name) -> Vec<u8> {
119    let mut w = TlvWriter::new();
120    w.write_nested(tlv_type::NAME, |w| {
121        for c in name.components() {
122            w.write_tlv(c.typ, &c.value);
123        }
124    });
125    w.finish().to_vec()
126}
127
128/// Hex-encode bytes in **uppercase** — matching ndn-cxx's
129/// `transform/hex-encode` filter, which `BackEndFile::toFileName` uses.
130fn upper_hex(bytes: &[u8]) -> String {
131    let mut s = String::with_capacity(bytes.len() * 2);
132    for b in bytes {
133        s.push_str(&format!("{b:02X}"));
134    }
135    s
136}
137
138/// Compute the on-disk filename stem (the SHA-256 hex prefix, no
139/// extension). Same for all kinds — the extension is appended by the
140/// caller depending on `TpmKeyKind`.
141fn filename_stem(key_name: &Name) -> String {
142    let wire = name_wire_encode(key_name);
143    let digest = Sha256::digest(&wire);
144    upper_hex(&digest)
145}
146
147// ─── Base64 (no-newline, no-armor) ──────────────────────────────────────────
148
149fn b64_encode(bytes: &[u8]) -> String {
150    use base64::Engine;
151    base64::engine::general_purpose::STANDARD.encode(bytes)
152}
153fn b64_decode(s: &str) -> Result<Vec<u8>, FileTpmError> {
154    use base64::Engine;
155    // Permissive: ignore embedded whitespace so files written by other
156    // tools with line wrapping still load cleanly. ndn-cxx's
157    // `base64Decode` filter does the same.
158    let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
159    base64::engine::general_purpose::STANDARD
160        .decode(cleaned.as_bytes())
161        .map_err(|e| FileTpmError::Base64(e.to_string()))
162}
163
164// ─── FileTpm ─────────────────────────────────────────────────────────────────
165
166/// File-backed TPM. Stores private keys under
167/// `<root>/<HEX>.privkey[-ed25519]` files and reads them back on
168/// demand. All operations take `&self`; concurrent access is safe
169/// because each call performs an independent open/read/close.
170pub struct FileTpm {
171    root: PathBuf,
172}
173
174impl FileTpm {
175    /// Open or create a TPM at the given directory. Creates the
176    /// directory tree (with `0o700` permissions) if absent.
177    pub fn open(root: impl AsRef<Path>) -> Result<Self, FileTpmError> {
178        let root = root.as_ref().to_path_buf();
179        fs::create_dir_all(&root)?;
180        // 0o700 — ndn-cxx omits this but we set it explicitly. Setting
181        // it is strictly safer than leaving it to umask, and it doesn't
182        // affect interop because ndn-cxx doesn't check directory mode.
183        #[cfg(unix)]
184        {
185            let _ = fs::set_permissions(&root, fs::Permissions::from_mode(0o700));
186        }
187        Ok(Self { root })
188    }
189
190    /// Open the default TPM at `$HOME/.ndn/ndnsec-key-file/`, mirroring
191    /// ndn-cxx `BackEndFile`'s default constructor.
192    pub fn open_default() -> Result<Self, FileTpmError> {
193        let dir = if let Ok(p) = std::env::var("TEST_HOME") {
194            PathBuf::from(p).join(".ndn").join("ndnsec-key-file")
195        } else if let Ok(p) = std::env::var("HOME") {
196            PathBuf::from(p).join(".ndn").join("ndnsec-key-file")
197        } else {
198            std::env::current_dir()?
199                .join(".ndn")
200                .join("ndnsec-key-file")
201        };
202        Self::open(dir)
203    }
204
205    /// Locator string the PIB persists for this TPM. Matches ndn-cxx's
206    /// canonical form: `tpm-file:` for the default location, or
207    /// `tpm-file:<absolute-path>` for a custom one. ndn-cxx's
208    /// `parseAndCheckTpmLocator` rejects mismatches at KeyChain open
209    /// time, so writing the wrong string here will break interop.
210    pub fn locator(&self) -> String {
211        // We can't easily tell whether `self.root` is "the default"
212        // without re-running the env var lookup, so always emit the
213        // explicit form. ndn-cxx accepts both.
214        format!("tpm-file:{}", self.root.display())
215    }
216
217    /// Path to a key file given its name and kind.
218    fn path_for(&self, key_name: &Name, kind: TpmKeyKind) -> PathBuf {
219        let stem = filename_stem(key_name);
220        self.root.join(format!("{stem}.{}", kind.extension()))
221    }
222
223    /// Save raw DER bytes for a key. The DER must already be in the
224    /// algorithm's canonical form for `kind`:
225    /// - `Rsa` → PKCS#1 `RSAPrivateKey`
226    /// - `EcdsaP256` → SEC1 `ECPrivateKey`
227    /// - `Ed25519` → PKCS#8 `PrivateKeyInfo`
228    ///
229    /// The bytes are base64-encoded and written with `0o400`.
230    pub fn save_raw(
231        &self,
232        key_name: &Name,
233        kind: TpmKeyKind,
234        der: &[u8],
235    ) -> Result<(), FileTpmError> {
236        let path = self.path_for(key_name, kind);
237        let body = b64_encode(der);
238        fs::write(&path, body.as_bytes())?;
239        #[cfg(unix)]
240        {
241            // ndn-cxx uses 0o400. Match exactly.
242            fs::set_permissions(&path, fs::Permissions::from_mode(0o400))?;
243        }
244        Ok(())
245    }
246
247    /// Load raw DER bytes for a key. Tries the `.privkey` file first
248    /// (RSA / ECDSA), then `.privkey-ed25519`. Returns the kind alongside
249    /// the bytes so callers can dispatch on algorithm.
250    pub fn load_raw(&self, key_name: &Name) -> Result<(TpmKeyKind, Vec<u8>), FileTpmError> {
251        let stem = filename_stem(key_name);
252
253        // Try .privkey first (the ndn-cxx-compatible file). Autodetect
254        // RSA vs ECDSA from the inner DER.
255        let primary = self.root.join(format!("{stem}.privkey"));
256        if let Ok(body) = fs::read_to_string(&primary) {
257            let der = b64_decode(&body)?;
258            let kind = autodetect_pkcs1_or_sec1(&der)?;
259            return Ok((kind, der));
260        }
261
262        // Then try the Ed25519 sentinel.
263        let secondary = self.root.join(format!("{stem}.privkey-ed25519"));
264        if let Ok(body) = fs::read_to_string(&secondary) {
265            let der = b64_decode(&body)?;
266            return Ok((TpmKeyKind::Ed25519, der));
267        }
268
269        Err(FileTpmError::KeyNotFound(format!("{key_name}")))
270    }
271
272    /// Delete a key file (whichever form exists).
273    pub fn delete(&self, key_name: &Name) -> Result<(), FileTpmError> {
274        let stem = filename_stem(key_name);
275        for ext in ["privkey", "privkey-ed25519"] {
276            let p = self.root.join(format!("{stem}.{ext}"));
277            if p.exists() {
278                fs::remove_file(p)?;
279            }
280        }
281        Ok(())
282    }
283
284    /// Check whether a key exists in the TPM.
285    pub fn has_key(&self, key_name: &Name) -> bool {
286        let stem = filename_stem(key_name);
287        self.root.join(format!("{stem}.privkey")).exists()
288            || self.root.join(format!("{stem}.privkey-ed25519")).exists()
289    }
290
291    // ── High-level: generate, sign, derive public key ───────────────────────
292
293    /// Generate a fresh Ed25519 key, persist it under the sentinel
294    /// suffix, and return the 32-byte raw seed. Callers that want a
295    /// `Signer` should pass the seed to `Ed25519Signer::from_seed`.
296    pub fn generate_ed25519(&self, key_name: &Name) -> Result<[u8; 32], FileTpmError> {
297        use ed25519_dalek::SigningKey;
298        use ed25519_dalek::pkcs8::EncodePrivateKey;
299
300        // Generate a fresh seed via OsRng (reuse the same source that
301        // ed25519-dalek's example uses; we don't need a separate RNG).
302        let mut seed = [0u8; 32];
303        ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut seed)
304            .map_err(|_| FileTpmError::Sign("rng failure".into()))?;
305        let sk = SigningKey::from_bytes(&seed);
306
307        // PKCS#8 PrivateKeyInfo for Ed25519 — pkcs8 1.0 / RFC 8410.
308        let pkcs8 = sk
309            .to_pkcs8_der()
310            .map_err(|e| FileTpmError::InvalidKey(format!("ed25519 pkcs8: {e}")))?;
311        self.save_raw(key_name, TpmKeyKind::Ed25519, pkcs8.as_bytes())?;
312        Ok(seed)
313    }
314
315    /// Sign `region` with the key stored under `key_name`. Returns raw
316    /// signature bytes. Algorithm is determined by which file form
317    /// exists on disk.
318    pub fn sign(&self, key_name: &Name, region: &[u8]) -> Result<Bytes, FileTpmError> {
319        let (kind, der) = self.load_raw(key_name)?;
320        match kind {
321            TpmKeyKind::Rsa => sign_rsa(&der, region),
322            TpmKeyKind::EcdsaP256 => sign_ecdsa_p256(&der, region),
323            TpmKeyKind::Ed25519 => sign_ed25519(&der, region),
324        }
325    }
326
327    /// Derive the public key bytes for `key_name`. Format matches what
328    /// the PIB's `key_bits` column expects: SubjectPublicKeyInfo DER
329    /// for RSA / ECDSA, raw 32-byte key for Ed25519.
330    pub fn public_key(&self, key_name: &Name) -> Result<Vec<u8>, FileTpmError> {
331        let (kind, der) = self.load_raw(key_name)?;
332        match kind {
333            TpmKeyKind::Rsa => public_key_rsa(&der),
334            TpmKeyKind::EcdsaP256 => public_key_ecdsa_p256(&der),
335            TpmKeyKind::Ed25519 => public_key_ed25519(&der),
336        }
337    }
338
339    // ── SafeBag import / export ──────────────────────────────────────────
340
341    /// Export `key_name` as a [`crate::safe_bag::SafeBag`] for transfer
342    /// to another machine. Bundles the password-encrypted private key
343    /// with the certificate the caller looked up from the PIB.
344    ///
345    /// The on-disk private key is converted to an unencrypted PKCS#8
346    /// `PrivateKeyInfo` first (RSA goes PKCS#1 → PKCS#8, ECDSA goes
347    /// SEC1 → PKCS#8, Ed25519 is already PKCS#8 on disk) and then
348    /// encrypted via PBES2 + PBKDF2-HMAC-SHA256 + AES-256-CBC inside
349    /// the rustcrypto `pkcs8` crate's `encrypt` method. The resulting
350    /// `EncryptedPrivateKeyInfo` is wire-compatible with what
351    /// `ndnsec export` and OpenSSL `i2d_PKCS8PrivateKey_bio` produce.
352    ///
353    /// **Caveat:** Ed25519 SafeBags roundtrip ndn-rs ↔ ndn-rs but not
354    /// to ndn-cxx, because ndn-cxx `tpm-file` has no Ed25519 path
355    /// regardless of how the bytes arrive on disk
356    /// (`back-end-file.cpp:130-139` rejects Ed25519 at the algorithm
357    /// switch). RSA and ECDSA-P256 SafeBags roundtrip with `ndnsec`
358    /// in both directions.
359    pub fn export_to_safebag(
360        &self,
361        key_name: &Name,
362        certificate: Bytes,
363        password: &[u8],
364    ) -> Result<crate::safe_bag::SafeBag, crate::safe_bag::SafeBagError> {
365        let (kind, der) = self.load_raw(key_name)?;
366        let pkcs8_der: Vec<u8> = match kind {
367            TpmKeyKind::Rsa => crate::safe_bag::rsa_pkcs1_to_pkcs8(&der)?,
368            TpmKeyKind::EcdsaP256 => crate::safe_bag::ec_sec1_to_pkcs8(&der)?,
369            TpmKeyKind::Ed25519 => der, // already PKCS#8 on disk
370        };
371        crate::safe_bag::SafeBag::encrypt(certificate, &pkcs8_der, password)
372    }
373
374    /// Import a [`crate::safe_bag::SafeBag`] as a stored private key
375    /// under `key_name`. Decrypts the embedded `EncryptedPrivateKeyInfo`
376    /// with `password`, dispatches on the PKCS#8 algorithm OID to
377    /// pick the on-disk format, converts back to the FileTpm form
378    /// (PKCS#1 / SEC1 / PKCS#8), and writes it.
379    ///
380    /// Returns the certificate Data wire bytes from the SafeBag so
381    /// the caller can insert them into their PIB. FileTpm itself
382    /// does not store certs — the certificate side of the bag is
383    /// the PIB's responsibility.
384    ///
385    /// `key_name` is an explicit argument because the SafeBag does
386    /// not record where the key should land in any particular PIB —
387    /// the caller is expected to extract it from the certificate's
388    /// Name (typically a prefix of the cert name) and pass it in.
389    pub fn import_from_safebag(
390        &self,
391        safebag: &crate::safe_bag::SafeBag,
392        key_name: &Name,
393        password: &[u8],
394    ) -> Result<Bytes, crate::safe_bag::SafeBagError> {
395        let pkcs8_der = safebag.decrypt_key(password)?;
396        let kind = crate::safe_bag::detect_pkcs8_algorithm(&pkcs8_der)?;
397        let on_disk: Vec<u8> = match kind {
398            TpmKeyKind::Rsa => crate::safe_bag::rsa_pkcs8_to_pkcs1(&pkcs8_der)?,
399            TpmKeyKind::EcdsaP256 => crate::safe_bag::ec_pkcs8_to_sec1(&pkcs8_der)?,
400            TpmKeyKind::Ed25519 => pkcs8_der, // on-disk form IS pkcs8
401        };
402        self.save_raw(key_name, kind, &on_disk)?;
403        Ok(safebag.certificate.clone())
404    }
405}
406
407// ─── Algorithm autodetection (RSA vs ECDSA from DER) ────────────────────────
408
409/// Look at the first few ASN.1 bytes to decide whether a `.privkey`
410/// file holds PKCS#1 RSA or SEC1 EC. ndn-cxx defers to OpenSSL's
411/// `d2i_AutoPrivateKey` for this; we do a coarse but reliable check
412/// based on the first SEQUENCE element:
413///
414/// - PKCS#1 `RSAPrivateKey` SEQUENCE { version INTEGER (0 or 1), n INTEGER, ... }
415///   → second element is a large INTEGER (the modulus), so the structure
416///   is `30 LL 02 01 vv 02 LL ...`.
417/// - SEC1 `ECPrivateKey`   SEQUENCE { version INTEGER (1), privateKey OCTET STRING, ... }
418///   → second element is OCTET STRING `04 LL ...`, so it starts
419///   `30 LL 02 01 01 04 LL ...`.
420///
421/// We dispatch on the byte at offset 5 (the second element's tag): `02`
422/// → RSA, `04` → ECDSA. Anything else is an error.
423fn autodetect_pkcs1_or_sec1(der: &[u8]) -> Result<TpmKeyKind, FileTpmError> {
424    if der.len() < 6 || der[0] != 0x30 {
425        return Err(FileTpmError::InvalidKey("not a DER SEQUENCE".into()));
426    }
427    // Skip outer length (1 or 2+ bytes) to find the inner version + next.
428    let mut i = 1usize;
429    let len_byte = der[i];
430    i += 1;
431    if len_byte & 0x80 != 0 {
432        i += (len_byte & 0x7F) as usize;
433    }
434    // Inner: version INTEGER must be `02 01 vv`.
435    if i + 3 > der.len() || der[i] != 0x02 || der[i + 1] != 0x01 {
436        return Err(FileTpmError::InvalidKey(
437            "inner version field missing".into(),
438        ));
439    }
440    let next_tag_idx = i + 3;
441    if next_tag_idx >= der.len() {
442        return Err(FileTpmError::InvalidKey("DER too short".into()));
443    }
444    match der[next_tag_idx] {
445        0x02 => Ok(TpmKeyKind::Rsa),       // INTEGER → RSA modulus
446        0x04 => Ok(TpmKeyKind::EcdsaP256), // OCTET STRING → SEC1 priv key
447        b => Err(FileTpmError::UnsupportedAlgorithm(format!(
448            "unknown second-element tag 0x{b:02x}"
449        ))),
450    }
451}
452
453// ─── Signing implementations ─────────────────────────────────────────────────
454
455fn sign_rsa(pkcs1_der: &[u8], region: &[u8]) -> Result<Bytes, FileTpmError> {
456    use pkcs1::DecodeRsaPrivateKey;
457    // Use the sha2 re-export bundled by `rsa` rather than our top-level
458    // `sha2 0.11`. `rsa = 0.9` is on the older rustcrypto release wave
459    // with `digest 0.10`, and `Pkcs1v15Sign::new::<D>` is bound to that
460    // crate's own `Digest` trait — handing it a `Sha256` from `sha2 0.11`
461    // (which implements `digest 0.11::Digest`) is a different trait and
462    // fails type-check. The bundled re-export gives us the exact type
463    // `Pkcs1v15Sign::new` expects. When we eventually bump `rsa` to
464    // 0.10 alongside the rest of the rustcrypto stack, this and the
465    // matching block in the test module can drop the `rsa::` prefix.
466    use rsa::sha2::{Digest, Sha256};
467    use rsa::{Pkcs1v15Sign, RsaPrivateKey};
468
469    let sk = RsaPrivateKey::from_pkcs1_der(pkcs1_der)
470        .map_err(|e| FileTpmError::InvalidKey(format!("rsa pkcs1: {e}")))?;
471
472    // NDN signs SHA-256(signed region) with PKCS#1 v1.5 padding —
473    // matching SignatureSha256WithRsa (TLV type 1).
474    let hash = Sha256::digest(region);
475    let sig = sk
476        .sign(Pkcs1v15Sign::new::<Sha256>(), &hash)
477        .map_err(|e| FileTpmError::Sign(format!("rsa sign: {e}")))?;
478    Ok(Bytes::from(sig))
479}
480
481fn public_key_rsa(pkcs1_der: &[u8]) -> Result<Vec<u8>, FileTpmError> {
482    use pkcs1::DecodeRsaPrivateKey;
483    use pkcs8::EncodePublicKey;
484    use rsa::RsaPrivateKey;
485
486    let sk = RsaPrivateKey::from_pkcs1_der(pkcs1_der)
487        .map_err(|e| FileTpmError::InvalidKey(format!("rsa pkcs1: {e}")))?;
488    let pk = sk.to_public_key();
489    // SubjectPublicKeyInfo DER — what the PIB key_bits column expects.
490    pk.to_public_key_der()
491        .map(|d| d.as_bytes().to_vec())
492        .map_err(|e| FileTpmError::InvalidKey(format!("rsa spki: {e}")))
493}
494
495/// Hand-extract the 32-byte private scalar from a SEC1 `ECPrivateKey`
496/// DER envelope for the P-256 curve. We bypass `SigningKey::from_sec1_der`
497/// because pairing it with `verifying_key()` triggers the spki crate's
498/// "AlgorithmIdentifier parameters missing" check on the embedded
499/// `publicKey [1] BIT STRING` field, which fails for SEC1 blobs that
500/// omit the optional parameters even though the curve is known
501/// statically. The wire layout we accept is:
502///
503/// ```text
504/// SEQUENCE {
505///   INTEGER version (== 1)              -- 02 01 01
506///   OCTET STRING privateKey (32 bytes)  -- 04 20 <X..32>
507///   [parameters [0] OPTIONAL]
508///   [publicKey [1] OPTIONAL]
509/// }
510/// ```
511///
512/// We only need the privateKey OCTET STRING to construct an ECDSA
513/// signing key; the rest of the envelope is intentionally ignored.
514pub(crate) fn parse_sec1_p256_priv_scalar(sec1: &[u8]) -> Result<[u8; 32], FileTpmError> {
515    if sec1.len() < 9 || sec1[0] != 0x30 {
516        return Err(FileTpmError::InvalidKey("not a SEC1 SEQUENCE".into()));
517    }
518    let mut i = 1usize;
519    let len_byte = sec1[i];
520    i += 1;
521    if len_byte & 0x80 != 0 {
522        // Long-form length: skip the length-of-length octets.
523        i += (len_byte & 0x7F) as usize;
524    }
525    if i + 3 > sec1.len() || sec1[i] != 0x02 || sec1[i + 1] != 0x01 {
526        return Err(FileTpmError::InvalidKey("expected version INTEGER".into()));
527    }
528    i += 3; // skip `02 01 vv`
529    if i + 2 > sec1.len() || sec1[i] != 0x04 {
530        return Err(FileTpmError::InvalidKey(
531            "expected privateKey OCTET STRING".into(),
532        ));
533    }
534    let key_len = sec1[i + 1] as usize;
535    if key_len != 32 {
536        return Err(FileTpmError::InvalidKey(format!(
537            "expected 32-byte P-256 scalar, got {key_len}"
538        )));
539    }
540    i += 2;
541    if i + 32 > sec1.len() {
542        return Err(FileTpmError::InvalidKey(
543            "SEC1 truncated in privateKey".into(),
544        ));
545    }
546    let mut out = [0u8; 32];
547    out.copy_from_slice(&sec1[i..i + 32]);
548    Ok(out)
549}
550
551/// Build an `ecdsa::SigningKey<NistP256>` from a SEC1 ECPrivateKey blob,
552/// bypassing the broken-on-missing-params `from_sec1_der` path.
553fn signing_key_from_sec1(sec1_der: &[u8]) -> Result<p256_ecdsa::ecdsa::SigningKey, FileTpmError> {
554    use p256_ecdsa::ecdsa::SigningKey;
555    let scalar = parse_sec1_p256_priv_scalar(sec1_der)?;
556    SigningKey::from_bytes((&scalar).into())
557        .map_err(|e| FileTpmError::InvalidKey(format!("ecdsa scalar: {e}")))
558}
559
560fn sign_ecdsa_p256(sec1_der: &[u8], region: &[u8]) -> Result<Bytes, FileTpmError> {
561    use p256_ecdsa::ecdsa::{Signature, signature::Signer};
562
563    let sk = signing_key_from_sec1(sec1_der)?;
564    // DER-encoded signature for ndn-cxx compatibility.
565    let sig: Signature = sk.sign(region);
566    Ok(Bytes::from(sig.to_der().as_bytes().to_vec()))
567}
568
569fn public_key_ecdsa_p256(sec1_der: &[u8]) -> Result<Vec<u8>, FileTpmError> {
570    let sk = signing_key_from_sec1(sec1_der)?;
571    // Uncompressed SEC1 point: 0x04 || X(32) || Y(32) = 65 bytes.
572    let point = sk.verifying_key().to_encoded_point(false);
573    let sec1_bytes = point.as_bytes();
574    debug_assert_eq!(sec1_bytes.len(), 65);
575    debug_assert_eq!(sec1_bytes[0], 0x04);
576    Ok(p256_spki_wrap(sec1_bytes))
577}
578
579/// Wrap a 65-byte P-256 uncompressed SEC1 point (`04 || X || Y`) in a
580/// canonical SubjectPublicKeyInfo DER, hand-built so we don't rely on
581/// the rustcrypto pkcs8 trait machinery (which is brittle across the
582/// 0.10/0.11 split when paired with elliptic-curve 0.13).
583///
584/// Output structure:
585///
586/// ```text
587/// SEQUENCE (0x30 0x59 = 89 bytes total inner) {
588///   SEQUENCE (0x30 0x13 = 19 bytes) {
589///     OID id-ecPublicKey  06 07 2A 86 48 CE 3D 02 01
590///     OID prime256v1      06 08 2A 86 48 CE 3D 03 01 07
591///   }
592///   BIT STRING (0x03 0x42 = 66 bytes) {
593///     00                  -- 0 unused bits
594///     04 X(32) Y(32)      -- uncompressed SEC1 point
595///   }
596/// }
597/// ```
598///
599/// Total length: 91 bytes (2 outer header + 21 algorithm + 68 bitstring).
600fn p256_spki_wrap(sec1_uncompressed: &[u8]) -> Vec<u8> {
601    const PREFIX: [u8; 26] = [
602        0x30, 0x59, // SEQUENCE, 89 bytes
603        0x30, 0x13, // SEQUENCE, 19 bytes (algorithm)
604        0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01, // OID id-ecPublicKey
605        0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, // OID prime256v1
606        0x03, 0x42, // BIT STRING, 66 bytes
607        0x00, // 0 unused bits
608    ];
609    let mut out = Vec::with_capacity(PREFIX.len() + sec1_uncompressed.len());
610    out.extend_from_slice(&PREFIX);
611    out.extend_from_slice(sec1_uncompressed);
612    out
613}
614
615fn sign_ed25519(pkcs8_der: &[u8], region: &[u8]) -> Result<Bytes, FileTpmError> {
616    use ed25519_dalek::Signer;
617    use ed25519_dalek::SigningKey;
618    use ed25519_dalek::pkcs8::DecodePrivateKey;
619
620    let sk = SigningKey::from_pkcs8_der(pkcs8_der)
621        .map_err(|e| FileTpmError::InvalidKey(format!("ed25519 pkcs8: {e}")))?;
622    let sig = sk.sign(region);
623    Ok(Bytes::copy_from_slice(&sig.to_bytes()))
624}
625
626fn public_key_ed25519(pkcs8_der: &[u8]) -> Result<Vec<u8>, FileTpmError> {
627    use ed25519_dalek::SigningKey;
628    use ed25519_dalek::pkcs8::DecodePrivateKey;
629
630    let sk = SigningKey::from_pkcs8_der(pkcs8_der)
631        .map_err(|e| FileTpmError::InvalidKey(format!("ed25519 pkcs8: {e}")))?;
632    Ok(sk.verifying_key().to_bytes().to_vec())
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use ndn_packet::NameComponent;
639    use tempfile::tempdir;
640
641    fn comp(s: &'static str) -> NameComponent {
642        NameComponent::generic(Bytes::from_static(s.as_bytes()))
643    }
644    fn name(parts: &[&'static str]) -> Name {
645        Name::from_components(parts.iter().map(|p| comp(p)))
646    }
647
648    #[test]
649    fn filename_stem_is_uppercase_sha256_of_wire() {
650        // Build a known name and verify the stem matches an
651        // independently-computed SHA-256(wire) hex.
652        let n = name(&["alice", "KEY", "k1"]);
653        let stem = filename_stem(&n);
654        // Compute expected: TLV (0x07 + len + 3 components) → SHA-256 → hex upper.
655        let mut wire = Vec::new();
656        // Outer header: 0x07, len=11+ inner. Just compare against the
657        // helper's own output to ensure stability across runs.
658        for c in n.components() {
659            wire.push(c.typ as u8);
660            wire.push(c.value.len() as u8);
661            wire.extend_from_slice(&c.value);
662        }
663        let inner_len = wire.len();
664        let mut full = Vec::new();
665        full.push(0x07);
666        full.push(inner_len as u8);
667        full.extend_from_slice(&wire);
668        let expected = upper_hex(&sha2::Sha256::digest(&full));
669        assert_eq!(stem, expected);
670        // Sanity: 64 hex chars = 32 bytes.
671        assert_eq!(stem.len(), 64);
672        // All uppercase hex.
673        assert!(
674            stem.chars()
675                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_lowercase())
676        );
677    }
678
679    #[test]
680    fn ed25519_save_load_sign_roundtrip() {
681        let dir = tempdir().unwrap();
682        let tpm = FileTpm::open(dir.path()).unwrap();
683        let kn = name(&["alice", "KEY", "k1"]);
684        let _seed = tpm.generate_ed25519(&kn).unwrap();
685        assert!(tpm.has_key(&kn));
686
687        let region = b"hello ndn-rs file tpm";
688        let sig = tpm.sign(&kn, region).unwrap();
689        assert_eq!(sig.len(), 64);
690
691        // Verify the signature using the TPM-derived public key.
692        use ed25519_dalek::Verifier;
693        use ed25519_dalek::{Signature, VerifyingKey};
694        let pk_bytes = tpm.public_key(&kn).unwrap();
695        let pk = VerifyingKey::from_bytes(&pk_bytes.as_slice().try_into().unwrap()).unwrap();
696        let sig_obj = Signature::from_bytes(&sig.as_ref().try_into().unwrap());
697        pk.verify(region, &sig_obj).unwrap();
698    }
699
700    #[test]
701    fn ecdsa_p256_save_load_sign_roundtrip() {
702        use p256_ecdsa::SecretKey;
703
704        let dir = tempdir().unwrap();
705        let tpm = FileTpm::open(dir.path()).unwrap();
706        let kn = name(&["bob", "KEY", "k1"]);
707
708        // Generate an ECDSA-P256 key via the elliptic-curve SecretKey
709        // surface (which directly implements EncodeEcPrivateKey) and
710        // store it as SEC1 DER, matching the ndn-cxx tpm-file format.
711        // `to_sec1_der` returns Zeroizing<Vec<u8>>; deref to a slice.
712        let sk = SecretKey::random(&mut rand_core_compat());
713        let der = sk.to_sec1_der().unwrap();
714        tpm.save_raw(&kn, TpmKeyKind::EcdsaP256, der.as_slice())
715            .unwrap();
716
717        // Re-detect on load.
718        let (kind, _der) = tpm.load_raw(&kn).unwrap();
719        assert_eq!(kind, TpmKeyKind::EcdsaP256);
720
721        let region = b"ecdsa test region";
722        let sig = tpm.sign(&kn, region).unwrap();
723        assert!(!sig.is_empty(), "sig must be non-empty");
724
725        // Verify with the recovered public key.
726        use p256_ecdsa::ecdsa::{Signature, VerifyingKey, signature::Verifier};
727        use pkcs8::DecodePublicKey;
728        let pk_der = tpm.public_key(&kn).unwrap();
729        let vk = VerifyingKey::from_public_key_der(&pk_der).unwrap();
730        let sig_obj = Signature::from_der(&sig).unwrap();
731        vk.verify(region, &sig_obj).unwrap();
732    }
733
734    #[test]
735    fn rsa_save_load_sign_roundtrip() {
736        use pkcs1::EncodeRsaPrivateKey;
737        use rsa::RsaPrivateKey;
738
739        let dir = tempdir().unwrap();
740        let tpm = FileTpm::open(dir.path()).unwrap();
741        let kn = name(&["carol", "KEY", "k1"]);
742
743        // 2048-bit key: small enough that the test runs in ~0.5 s.
744        let mut rng = rand_core_compat();
745        let sk = RsaPrivateKey::new(&mut rng, 2048).unwrap();
746        let der = sk.to_pkcs1_der().unwrap();
747        tpm.save_raw(&kn, TpmKeyKind::Rsa, der.as_bytes()).unwrap();
748
749        let (kind, _) = tpm.load_raw(&kn).unwrap();
750        assert_eq!(kind, TpmKeyKind::Rsa);
751
752        let region = b"rsa test region";
753        let sig = tpm.sign(&kn, region).unwrap();
754        // 2048-bit RSA signature is 256 bytes.
755        assert_eq!(sig.len(), 256);
756
757        // Verify using the recovered public key. As in `sign_rsa`, we
758        // use rsa's bundled `sha2` re-export so the `Pkcs1v15Sign::new`
759        // type bound is satisfied — the workspace's top-level
760        // `sha2 0.11::Sha256` belongs to a different `Digest` trait
761        // family.
762        use pkcs8::DecodePublicKey;
763        use rsa::sha2::{Digest, Sha256};
764        use rsa::{Pkcs1v15Sign, RsaPublicKey};
765        let pk_der = tpm.public_key(&kn).unwrap();
766        let pk = RsaPublicKey::from_public_key_der(&pk_der).unwrap();
767        let hash = Sha256::digest(region);
768        pk.verify(Pkcs1v15Sign::new::<Sha256>(), &hash, &sig)
769            .unwrap();
770    }
771
772    #[test]
773    fn delete_removes_both_extensions() {
774        let dir = tempdir().unwrap();
775        let tpm = FileTpm::open(dir.path()).unwrap();
776        let kn = name(&["alice", "KEY", "k1"]);
777        tpm.generate_ed25519(&kn).unwrap();
778        assert!(tpm.has_key(&kn));
779        tpm.delete(&kn).unwrap();
780        assert!(!tpm.has_key(&kn));
781    }
782
783    #[test]
784    fn load_missing_key_returns_not_found() {
785        let dir = tempdir().unwrap();
786        let tpm = FileTpm::open(dir.path()).unwrap();
787        let kn = name(&["nobody"]);
788        match tpm.load_raw(&kn) {
789            Err(FileTpmError::KeyNotFound(_)) => {}
790            other => panic!("expected KeyNotFound, got {other:?}"),
791        }
792    }
793
794    #[test]
795    fn locator_string_is_canonical() {
796        let dir = tempdir().unwrap();
797        let tpm = FileTpm::open(dir.path()).unwrap();
798        let loc = tpm.locator();
799        assert!(loc.starts_with("tpm-file:"));
800        assert!(loc.contains(&dir.path().display().to_string()));
801    }
802
803    #[test]
804    fn autodetect_distinguishes_rsa_and_ecdsa() {
805        // RSA SEQUENCE: 30 LL 02 01 00 02 ...
806        let rsa_like = [0x30, 0x82, 0x01, 0x00, 0x02, 0x01, 0x00, 0x02, 0x82];
807        assert_eq!(
808            autodetect_pkcs1_or_sec1(&rsa_like).unwrap(),
809            TpmKeyKind::Rsa
810        );
811        // SEC1 SEQUENCE: 30 LL 02 01 01 04 LL ...
812        let ec_like = [0x30, 0x77, 0x02, 0x01, 0x01, 0x04, 0x20];
813        assert_eq!(
814            autodetect_pkcs1_or_sec1(&ec_like).unwrap(),
815            TpmKeyKind::EcdsaP256
816        );
817    }
818
819    /// Bridge helper: rsa 0.9 and p256 0.13 both use rand_core 0.6
820    /// traits internally, and `rsa` re-exports `rand_core` so we get
821    /// a stable handle without adding rand_core to our deps directly.
822    /// `OsRng` satisfies the `CryptoRngCore` bound both crates need.
823    fn rand_core_compat() -> rsa::rand_core::OsRng {
824        rsa::rand_core::OsRng
825    }
826
827    // ── SafeBag roundtrip tests ───────────────────────────────────────────
828    //
829    // For each supported algorithm, generate or import a key into TPM
830    // A, export to a SafeBag, decode the SafeBag wire bytes, import
831    // into a fresh TPM B, and verify the imported key produces a
832    // signature that the original key's public key can verify. This
833    // exercises the full path:
834    //
835    //   on-disk → PKCS#8 → encrypt → SafeBag TLV → decode → decrypt
836    //   → PKCS#8 → on-disk → load → sign
837    //
838    // If any link in that chain has a format error, the verify at the
839    // end fails. A wrong password also fails decryption (separately
840    // tested in safe_bag.rs).
841
842    fn fake_cert_bytes() -> Bytes {
843        // SafeBag treats the certificate as opaque; any well-formed
844        // Data TLV is fine for a roundtrip test.
845        use ndn_tlv::TlvWriter;
846        let mut w = TlvWriter::new();
847        w.write_tlv(0x06, b"placeholder cert body");
848        w.finish()
849    }
850
851    #[test]
852    fn safebag_ed25519_roundtrip() {
853        let dir_a = tempdir().unwrap();
854        let dir_b = tempdir().unwrap();
855        let tpm_a = FileTpm::open(dir_a.path()).unwrap();
856        let tpm_b = FileTpm::open(dir_b.path()).unwrap();
857        let kn = name(&["alice", "KEY", "k1"]);
858        let pw = b"transfer-password";
859
860        // Generate Ed25519 in tpm_a, export to SafeBag, transport
861        // through wire bytes, import into tpm_b.
862        tpm_a.generate_ed25519(&kn).unwrap();
863        let region = b"hello safe bag";
864        let sig_a = tpm_a.sign(&kn, region).unwrap();
865
866        let sb = tpm_a.export_to_safebag(&kn, fake_cert_bytes(), pw).unwrap();
867        let wire = sb.encode();
868        let sb2 = crate::safe_bag::SafeBag::decode(&wire).unwrap();
869        let cert_back = tpm_b.import_from_safebag(&sb2, &kn, pw).unwrap();
870        assert_eq!(cert_back, fake_cert_bytes());
871
872        // The imported key must produce identical signatures (Ed25519
873        // is deterministic, so byte-equality holds).
874        let sig_b = tpm_b.sign(&kn, region).unwrap();
875        assert_eq!(sig_a, sig_b, "imported Ed25519 must produce same sig");
876    }
877
878    #[test]
879    fn safebag_ecdsa_roundtrip() {
880        use p256_ecdsa::SecretKey;
881
882        let dir_a = tempdir().unwrap();
883        let dir_b = tempdir().unwrap();
884        let tpm_a = FileTpm::open(dir_a.path()).unwrap();
885        let tpm_b = FileTpm::open(dir_b.path()).unwrap();
886        let kn = name(&["bob", "KEY", "k1"]);
887        let pw = b"transfer-password";
888
889        // Generate an ECDSA key, save as SEC1 (FileTpm on-disk form).
890        let sk = SecretKey::random(&mut rand_core_compat());
891        let der = sk.to_sec1_der().unwrap();
892        tpm_a
893            .save_raw(&kn, TpmKeyKind::EcdsaP256, der.as_slice())
894            .unwrap();
895
896        // Export → wire → decode → import.
897        let sb = tpm_a.export_to_safebag(&kn, fake_cert_bytes(), pw).unwrap();
898        let wire = sb.encode();
899        let sb2 = crate::safe_bag::SafeBag::decode(&wire).unwrap();
900        tpm_b.import_from_safebag(&sb2, &kn, pw).unwrap();
901
902        // ECDSA is non-deterministic so signatures won't byte-match;
903        // verify both signatures against both public keys instead.
904        let region = b"ecdsa safe bag region";
905        let sig_b = tpm_b.sign(&kn, region).unwrap();
906
907        // Recover the public key from tpm_a (the original) and verify
908        // the imported tpm_b's signature against it. If the SafeBag
909        // chain corrupted the key in any way, this verify fails.
910        use p256_ecdsa::ecdsa::{Signature, VerifyingKey, signature::Verifier};
911        use pkcs8::DecodePublicKey;
912        let pk_a_der = tpm_a.public_key(&kn).unwrap();
913        let vk_a = VerifyingKey::from_public_key_der(&pk_a_der).unwrap();
914        let sig_obj = Signature::from_der(&sig_b).unwrap();
915        vk_a.verify(region, &sig_obj)
916            .expect("imported ECDSA signature must verify against original public key");
917    }
918
919    #[test]
920    fn safebag_rsa_roundtrip() {
921        use pkcs1::EncodeRsaPrivateKey;
922        use rsa::RsaPrivateKey;
923
924        let dir_a = tempdir().unwrap();
925        let dir_b = tempdir().unwrap();
926        let tpm_a = FileTpm::open(dir_a.path()).unwrap();
927        let tpm_b = FileTpm::open(dir_b.path()).unwrap();
928        let kn = name(&["carol", "KEY", "k1"]);
929        let pw = b"transfer-password";
930
931        // 1024-bit key for test speed.
932        let mut rng = rand_core_compat();
933        let sk = RsaPrivateKey::new(&mut rng, 1024).unwrap();
934        let der = sk.to_pkcs1_der().unwrap();
935        tpm_a
936            .save_raw(&kn, TpmKeyKind::Rsa, der.as_bytes())
937            .unwrap();
938
939        let sb = tpm_a.export_to_safebag(&kn, fake_cert_bytes(), pw).unwrap();
940        let wire = sb.encode();
941        let sb2 = crate::safe_bag::SafeBag::decode(&wire).unwrap();
942        tpm_b.import_from_safebag(&sb2, &kn, pw).unwrap();
943
944        // RSA PKCS#1 v1.5 signing is deterministic — the imported key
945        // must produce byte-identical signatures.
946        let region = b"rsa safe bag region";
947        let sig_a = tpm_a.sign(&kn, region).unwrap();
948        let sig_b = tpm_b.sign(&kn, region).unwrap();
949        assert_eq!(
950            sig_a, sig_b,
951            "imported RSA must produce same deterministic sig"
952        );
953    }
954
955    #[test]
956    fn safebag_wrong_password_fails_import() {
957        let dir_a = tempdir().unwrap();
958        let dir_b = tempdir().unwrap();
959        let tpm_a = FileTpm::open(dir_a.path()).unwrap();
960        let tpm_b = FileTpm::open(dir_b.path()).unwrap();
961        let kn = name(&["alice", "KEY", "k1"]);
962
963        tpm_a.generate_ed25519(&kn).unwrap();
964        let sb = tpm_a
965            .export_to_safebag(&kn, fake_cert_bytes(), b"correct")
966            .unwrap();
967
968        match tpm_b.import_from_safebag(&sb, &kn, b"wrong") {
969            Err(crate::safe_bag::SafeBagError::Pkcs8(_)) => {}
970            other => panic!("expected Pkcs8 decrypt error, got {other:?}"),
971        }
972    }
973}